Feature livestream scheduling (#458)

Add livestream scheduling feature
Also supports back to back streams, and will notify on a non-active stream of an active one.
This commit is contained in:
Dan Peterson 2021-12-16 15:59:13 -06:00 committed by GitHub
parent f112721398
commit 038692cafc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1245 additions and 378 deletions

View file

@ -21,13 +21,26 @@ declare type LivestreamReplayItem = {
}
declare type LivestreamReplayData = Array<LivestreamReplayItem>;
declare type CurrentLiveClaim = {
claimId: string | null,
claimUri: string | null,
}
declare type LivestreamChannelStatus = {
channelId: null | string,
isBroadcasting: boolean,
liveClaim: CurrentLiveClaim,
}
declare type LivestreamState = {
fetchingById: {},
viewersById: {},
fetchingActiveLivestreams: boolean,
fetchingActiveLivestreams: boolean | string,
activeLivestreams: ?LivestreamInfo,
activeLivestreamsLastFetchedDate: number,
activeLivestreamsLastFetchedOptions: {},
currentChannelStatus: LivestreamChannelStatus,
}
declare type LivestreamInfo = {
@ -35,7 +48,7 @@ declare type LivestreamInfo = {
live: boolean,
viewCount: number,
creatorId: string,
latestClaimId: string,
latestClaimUri: string,
claimId: string,
claimUri: string,
}
}

View file

@ -67,7 +67,7 @@
"player.js": "^0.1.0",
"proxy-polyfill": "0.1.6",
"re-reselect": "^4.0.0",
"react-datetime-picker": "^3.2.1",
"react-datetime-picker": "^3.4.3",
"react-plastic": "^1.1.1",
"react-top-loading-bar": "^2.0.1",
"remove-markdown": "^0.3.0",

View file

@ -1795,7 +1795,6 @@
"Create A LiveStream": "Create A LiveStream",
"%channel% has disabled chat for this stream. Enjoy the stream!": "%channel% has disabled chat for this stream. Enjoy the stream!",
"This channel has disabled chat for this stream. Enjoy the stream!": "This channel has disabled chat for this stream. Enjoy the stream!",
"%channel% isn't live right now, but the chat is! Check back later to watch the stream.": "%channel% isn't live right now, but the chat is! Check back later to watch the stream.",
"This channel isn't live right now, but the chat is! Check back later to watch the stream.": "This channel isn't live right now, but the chat is! Check back later to watch the stream.",
"Right now": "Right now",
"%viewer_count% currently watching": "%viewer_count% currently watching",
@ -1805,7 +1804,7 @@
"Livestream": "Livestream",
"Your stream key": "Your stream key",
"Stream server": "Stream server",
"Stream key": "Stream key",
"Stream key (can be reused)": "Stream key (can be reused)",
"Your livestream uploads": "Your livestream uploads",
"Your pending livestream uploads": "Your pending livestream uploads",
"No livestream publishes found": "No livestream publishes found",
@ -2207,5 +2206,10 @@
"Cookies": "Cookies",
"Did someone invite you to use Odysee? Tell us who and you both get a reward!": "Did someone invite you to use Odysee? Tell us who and you both get a reward!",
"There are unsaved settings. Exit the Settings Page to finalize them.": "There are unsaved settings. Exit the Settings Page to finalize them.",
"For streaming from your mobile device, we recommend PRISM Live Studio from the app store.": "For streaming from your mobile device, we recommend PRISM Live Studio from the app store.",
"Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.": "Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.",
"Confirmation process takes a few minutes, but then you can go live anytime. The stream is not shown anywhere until you are broadcasting.": "Confirmation process takes a few minutes, but then you can go live anytime. The stream is not shown anywhere until you are broadcasting.",
"Your scheduled streams will appear on your channel page and for your followers. Chat will not be active until 5 minutes before the start time.": "Your scheduled streams will appear on your channel page and for your followers. Chat will not be active until 5 minutes before the start time.",
"Update or Publish Replay": "Update or Publish Replay",
"--end--": "--end--"
}

View file

@ -13,6 +13,8 @@ import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { withRouter } from 'react-router';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings';
import { doFetchActiveLivestream } from 'redux/actions/livestream';
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
import ChannelContent from './view';
@ -32,11 +34,13 @@ const select = (state, props) => {
isAuthenticated: selectUserVerifiedEmail(state),
showMature: selectShowMatureContent(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
currentChannelStatus: selectCurrentChannelStatus(state),
};
};
const perform = (dispatch) => ({
doResolveUris: (uris, returnCachedUris) => dispatch(doResolveUris(uris, returnCachedUris)),
doFetchActiveLivestream: (channelID) => dispatch(doFetchActiveLivestream(channelID)),
});
export default withRouter(connect(select, perform)(ChannelContent));

View file

@ -13,6 +13,7 @@ import LivestreamLink from 'component/livestreamLink';
import { Form, FormField } from 'component/common/form';
import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search';
import { lighthouse } from 'redux/actions/search';
import ScheduledStreams from 'component/scheduledStreams';
const TYPES_TO_ALLOW_FILTER = ['stream', 'repost'];
@ -36,6 +37,8 @@ type Props = {
doResolveUris: (Array<string>, boolean) => void,
claimType: string,
empty?: string,
doFetchActiveLivestream: (string) => void,
currentChannelStatus: LivestreamChannelStatus,
};
function ChannelContent(props: Props) {
@ -55,6 +58,8 @@ function ChannelContent(props: Props) {
doResolveUris,
claimType,
empty,
doFetchActiveLivestream,
currentChannelStatus,
} = props;
// const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
const claimsInChannel = 9999;
@ -65,6 +70,7 @@ function ChannelContent(props: Props) {
} = useHistory();
const url = `${pathname}${search}`;
const claimId = claim && claim.claim_id;
const isChannelEmpty = !claim || !claim.meta;
const showFilters =
!claimType ||
(Array.isArray(claimType)
@ -239,20 +245,52 @@ function ChannelContent(props: Props) {
}
}, DEBOUNCE_WAIT_DURATION_MS);
return () => clearTimeout(timer);
}, [claimId, searchQuery, showMature]);
}, [claimId, searchQuery, showMature, doResolveUris]);
React.useEffect(() => {
setSearchQuery('');
setSearchResults(null);
}, [url]);
const [isInitialized, setIsInitialized] = React.useState(false);
const [isChannelBroadcasting, setIsChannelBroadcasting] = React.useState(false);
// Find out current channels status + active live claim.
React.useEffect(() => {
doFetchActiveLivestream(claimId);
const intervalId = setInterval(() => doFetchActiveLivestream(claimId), 30000);
return () => clearInterval(intervalId);
}, [claimId, doFetchActiveLivestream]);
React.useEffect(() => {
const initialized = currentChannelStatus.channelId === claimId;
setIsInitialized(initialized);
if (initialized) {
setIsChannelBroadcasting(currentChannelStatus.isBroadcasting);
}
}, [currentChannelStatus, claimId]);
const showScheduledLiveStreams = claimType !== 'collection'; // ie. not on the playlist page.
return (
<Fragment>
{!fetching && Boolean(claimsInChannel) && !channelIsBlocked && !channelIsBlackListed && (
<HiddenNsfwClaims uri={uri} />
)}
<LivestreamLink uri={uri} />
{!fetching && isInitialized && isChannelBroadcasting && !isChannelEmpty && (
<LivestreamLink claimUri={currentChannelStatus.liveClaim.claimUri} />
)}
{!fetching && showScheduledLiveStreams && (
<ScheduledStreams
channelIds={[claimId]}
tileLayout={tileLayout}
liveUris={
isChannelBroadcasting && currentChannelStatus.liveClaim ? [currentChannelStatus.liveClaim.claimUri] : []
}
/>
)}
{!fetching && channelIsBlackListed && (
<section className="card card--section">
@ -277,42 +315,44 @@ function ChannelContent(props: Props) {
<Ads type="homepage" />
<ClaimListDiscover
hasSource
defaultFreshness={CS.FRESH_ALL}
showHiddenByUser={viewHiddenChannels}
forceShowReposts
fetchViewCount
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
uris={searchResults}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
channelIds={[claimId]}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
isChannel
channelIsMine={channelIsMine}
empty={empty}
/>
{!fetching && (
<ClaimListDiscover
hasSource
defaultFreshness={CS.FRESH_ALL}
showHiddenByUser={viewHiddenChannels}
forceShowReposts
fetchViewCount
hideFilters={!showFilters}
hideAdvancedFilter={!showFilters}
tileLayout={tileLayout}
uris={searchResults}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
channelIds={[claimId]}
claimType={claimType}
feeAmount={CS.FEE_AMOUNT_ANY}
defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
meta={
showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
className="wunderbar__input--inline"
value={searchQuery}
onChange={handleInputChange}
type="text"
placeholder={__('Search')}
/>
</Form>
)
}
isChannel
channelIsMine={channelIsMine}
empty={empty}
/>
)}
</Fragment>
);
}

View file

@ -44,6 +44,9 @@ type Props = {
collectionId?: string,
showNoSourceClaims?: boolean,
onClick?: (e: any, claim?: ?Claim, index?: number) => void,
maxClaimRender?: number,
excludeUris?: Array<string>,
loadedCallback?: (number) => void,
};
export default function ClaimList(props: Props) {
@ -74,6 +77,9 @@ export default function ClaimList(props: Props) {
collectionId,
showNoSourceClaims,
onClick,
maxClaimRender,
excludeUris = [],
loadedCallback,
} = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
@ -83,8 +89,15 @@ export default function ClaimList(props: Props) {
const timedOut = uris === null;
const urisLength = (uris && uris.length) || 0;
const tileUris = (prefixUris || []).concat(uris);
const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || [];
let tileUris = (prefixUris || []).concat(uris || []);
tileUris = tileUris.filter((uri) => !excludeUris.includes(uri));
if (maxClaimRender) tileUris = tileUris.slice(0, maxClaimRender);
let sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || [];
React.useEffect(() => {
if (typeof loadedCallback === 'function') loadedCallback(tileUris.length || 0);
}, [tileUris.length]); // eslint-disable-line react-hooks/exhaustive-deps
const noResultMsg = searchInLanguage
? __('No results. Contents may be hidden by the Language filter.')

View file

@ -0,0 +1,5 @@
import { createContext } from 'react';
const ClaimListDiscoverContext = createContext();
export default ClaimListDiscoverContext;

View file

@ -94,6 +94,12 @@ type Props = {
// --- perform ---
doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void,
hideLayoutButton?: boolean,
loadedCallback?: (number) => void,
maxClaimRender?: number,
useSkeletonScreen?: boolean,
excludeUris?: Array<string>,
};
function ClaimListDiscover(props: Props) {
@ -158,6 +164,11 @@ function ClaimListDiscover(props: Props) {
empty,
claimsByUri,
doFetchViewCount,
hideLayoutButton = false,
loadedCallback,
maxClaimRender,
useSkeletonScreen = true,
excludeUris = [],
} = props;
const didNavigateForward = history.action === 'PUSH';
const { search } = location;
@ -515,12 +526,21 @@ function ClaimListDiscover(props: Props) {
}
function resolveOrderByOption(orderBy: string | Array<string>, sortBy: string | Array<string>) {
const order_by =
orderBy === CS.ORDER_BY_TRENDING
? CS.ORDER_BY_TRENDING_VALUE
: orderBy === CS.ORDER_BY_NEW
? CS.ORDER_BY_NEW_VALUE
: CS.ORDER_BY_TOP_VALUE;
let order_by;
switch (orderBy) {
case CS.ORDER_BY_TRENDING:
order_by = CS.ORDER_BY_TRENDING_VALUE;
break;
case CS.ORDER_BY_NEW:
order_by = CS.ORDER_BY_NEW_VALUE;
break;
case CS.ORDER_BY_NEW_ASC:
order_by = CS.ORDER_BY_NEW_ASC_VALUE;
break;
default:
order_by = CS.ORDER_BY_TOP_VALUE;
}
if (orderBy === CS.ORDER_BY_NEW && sortBy === CS.SORT_BY.OLDEST.key) {
return order_by.map((x) => `${CS.SORT_BY.OLDEST.opt}${x}`);
@ -578,6 +598,7 @@ function ClaimListDiscover(props: Props) {
hiddenNsfwMessage={hiddenNsfwMessage}
setPage={setPage}
tileLayout={tileLayout}
hideLayoutButton={hideLayoutButton}
hideFilters={hideFilters}
scrollAnchor={scrollAnchor}
/>
@ -610,8 +631,11 @@ function ClaimListDiscover(props: Props) {
searchOptions={options}
showNoSourceClaims={showNoSourceClaims}
empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
loadedCallback={loadedCallback}
/>
{loading && (
{loading && useSkeletonScreen && (
<div className="claim-grid">
{new Array(dynamicPageSize).fill(1).map((x, i) => (
<ClaimPreviewTile key={i} placeholder="loading" />
@ -643,8 +667,12 @@ function ClaimListDiscover(props: Props) {
searchOptions={options}
showNoSourceClaims={hasNoSource || showNoSourceClaims}
empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
loadedCallback={loadedCallback}
/>
{loading &&
useSkeletonScreen &&
new Array(dynamicPageSize)
.fill(1)
.map((x, i) => (

View file

@ -24,6 +24,7 @@ type Props = {
orderBy?: Array<string>,
defaultOrderBy?: string,
hideAdvancedFilter: boolean,
hideLayoutButton: boolean,
hasMatureTags: boolean,
hiddenNsfwMessage?: Node,
channelIds?: Array<string>,
@ -49,6 +50,7 @@ function ClaimListHeader(props: Props) {
orderBy,
defaultOrderBy,
hideAdvancedFilter,
hideLayoutButton,
hasMatureTags,
hiddenNsfwMessage,
channelIds,
@ -269,7 +271,7 @@ function ClaimListHeader(props: Props) {
/>
)}
{tileLayout !== undefined && (
{tileLayout !== undefined && !hideLayoutButton && (
<Button
onClick={() => {
doSetClientSetting(SETTINGS.TILE_LAYOUT, !tileLayout);

View file

@ -5,16 +5,18 @@ import { doClearPublish, doPrepareEdit } from 'redux/actions/publish';
import { push } from 'connected-react-router';
import ClaimPreviewSubtitle from './view';
import { doFetchSubCount, selectSubCountForUri } from 'lbryinc';
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
const select = (state, props) => {
const claim = selectClaimForUri(state, props.uri);
const isChannel = claim && claim.value_type === 'channel';
const isLivestream = isStreamPlaceholderClaim(claim);
return {
claim,
pending: makeSelectClaimIsPending(props.uri)(state),
isLivestream: isStreamPlaceholderClaim(claim),
isLivestream,
subCount: isChannel ? selectSubCountForUri(state, props.uri) : 0,
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
};
};

View file

@ -1,26 +1,29 @@
// @flow
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import React from 'react';
import React, { useContext } from 'react';
import UriIndicator from 'component/uriIndicator';
import DateTime from 'component/dateTime';
import Button from 'component/button';
import FileViewCountInline from 'component/fileViewCountInline';
import { parseURI } from 'util/lbryURI';
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
import moment from 'moment';
type Props = {
uri: string,
claim: ?Claim,
claim: ?StreamClaim,
pending?: boolean,
type: string,
beginPublish: (?string) => void,
isLivestream: boolean,
fetchSubCount: (string) => void,
subCount: number,
isLivestreamActive: boolean,
};
// previews used in channel overview and homepage (and other places?)
function ClaimPreviewSubtitle(props: Props) {
const { pending, uri, claim, type, beginPublish, isLivestream, fetchSubCount, subCount } = props;
const { pending, uri, claim, type, beginPublish, isLivestream, isLivestreamActive, fetchSubCount, subCount } = props;
const isChannel = claim && claim.value_type === 'channel';
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
@ -38,6 +41,29 @@ function ClaimPreviewSubtitle(props: Props) {
({ streamName: name } = parseURI(uri));
} catch (e) {}
const { listingType } = useContext(ClaimListDiscoverContext) || {};
const LivestreamDateTimeLabel = () => {
// If showing in upcoming and in the past. (we allow x time in past to show here if not live yet)
if (listingType === 'UPCOMING') {
// $FlowFixMe
if (moment.unix(claim.value.release_time).isBefore()) {
return __('Starting Soon');
}
} else {
// If not in upcoming + live and in the future (started streaming a bit early)
// $FlowFixMe
if (isLivestreamActive && moment.unix(claim.value.release_time).isAfter()) {
return __('Streaming Now');
}
}
return (
<>
{__('Livestream')} <DateTime timeAgo uri={uri} />
</>
);
};
return (
<div className="media__subtitle">
{claim ? (
@ -56,7 +82,7 @@ function ClaimPreviewSubtitle(props: Props) {
{!isChannel &&
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? (
__('Livestream')
<LivestreamDateTimeLabel />
) : (
<>
<FileViewCountInline uri={uri} isLivestream={isLivestream} />

View file

@ -1,5 +1,5 @@
// @flow
import React from 'react';
import React, { useContext } from 'react';
import classnames from 'classnames';
import { NavLink, withRouter } from 'react-router-dom';
import FileThumbnail from 'component/fileThumbnail';
@ -19,6 +19,8 @@ import FileWatchLaterLink from 'component/fileWatchLaterLink';
import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
import moment from 'moment';
// $FlowFixMe cannot resolve ...
import PlaceholderTx from 'static/img/placeholderTx.gif';
@ -108,6 +110,8 @@ function ClaimPreviewTile(props: Props) {
}
}
const { listingType } = useContext(ClaimListDiscoverContext) || {};
const signingChannel = claim && claim.signing_channel;
const isChannel = claim && claim.value_type === 'channel';
const channelUri = !isChannel ? signingChannel && signingChannel.permanent_url : claim && claim.permanent_url;
@ -167,10 +171,27 @@ function ClaimPreviewTile(props: Props) {
}
let liveProperty = null;
if (isLivestreamActive === true) {
if (isLivestream === true) {
liveProperty = (claim) => <>LIVE</>;
}
const LivestreamDateTimeLabel = () => {
// If showing in upcoming and in the past. (we allow x time in past to show here if not live yet)
if (listingType === 'UPCOMING') {
// $FlowFixMe
if (moment.unix(claim.value.release_time).isBefore()) {
return __('Starting Soon');
}
} else {
// If not in upcoming + live and in the future (started streaming a bit early)
// $FlowFixMe
if (isLivestreamActive && moment.unix(claim.value.release_time).isAfter()) {
return __('Streaming Now');
}
}
return <DateTime timeAgo uri={uri} />;
};
return (
<li
onClick={handleClick}
@ -239,7 +260,8 @@ function ClaimPreviewTile(props: Props) {
<UriIndicator uri={uri} link />
<div className="claim-tile__about--counts">
<FileViewCountInline uri={uri} isLivestream={isLivestream} />
<DateTime timeAgo uri={uri} />
{isLivestream && <LivestreamDateTimeLabel />}
{!isLivestream && <DateTime timeAgo uri={uri} />}
</div>
</div>
</React.Fragment>

View file

@ -151,7 +151,7 @@ function FileActions(props: Props) {
<Button
className="button--file-action"
icon={ICONS.EDIT}
label={isLivestreamClaim ? __('Update') : __('Edit')}
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
navigate={`/$/${PAGES.UPLOAD}`}
onClick={() => {
prepareEdit(claim, editUri, fileInfo);

View file

@ -18,7 +18,7 @@ function FileSubtitle(props: Props) {
<>
<div className="media__subtitle--between">
<div className="file__viewdate">
{livestream ? <span>{__('Right now')}</span> : <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
{livestream && !isLive && <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
<FileViewCount uri={uri} livestream={livestream} isLive={isLive} />
</div>

View file

@ -2,7 +2,6 @@ import { connect } from 'react-redux';
import { selectClaimIdForUri } from 'redux/selectors/claims';
import { selectViewersForId } from 'redux/selectors/livestream';
import { doFetchViewCount, selectViewCountForUri } from 'lbryinc';
import { doAnalyticsView } from 'redux/actions/app';
import FileViewCount from './view';
const select = (state, props) => {
@ -16,7 +15,6 @@ const select = (state, props) => {
const perform = (dispatch) => ({
fetchViewCount: (claimId) => dispatch(doFetchViewCount(claimId)),
doAnalyticsView: (uri) => dispatch(doAnalyticsView(uri)),
});
export default connect(select, perform)(FileViewCount);

View file

@ -12,25 +12,17 @@ type Props = {
uri: string,
viewCount: string,
activeViewers?: number,
doAnalyticsView: (string) => void,
};
function FileViewCount(props: Props) {
const { claimId, uri, fetchViewCount, viewCount, livestream, activeViewers, isLive = false, doAnalyticsView } = props;
React.useEffect(() => {
if (livestream) {
// 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
doAnalyticsView(uri);
}
}, [livestream, doAnalyticsView, uri]);
const { claimId, fetchViewCount, viewCount, livestream, activeViewers, isLive = false } = props;
// @Note: it's important this only runs once on initial render.
React.useEffect(() => {
if (claimId) {
fetchViewCount(claimId);
}
}, [fetchViewCount, uri, claimId]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const formattedViewCount = Number(viewCount).toLocaleString();

View file

@ -2,7 +2,6 @@ import { connect } from 'react-redux';
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
import { doResolveUris } from 'redux/actions/claims';
import { selectClaimForUri, selectMyClaimIdsRaw } from 'redux/selectors/claims';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import {
selectTopLevelCommentsForUri,
@ -25,8 +24,6 @@ const select = (state, props) => ({
});
export default connect(select, {
doCommentSocketConnect,
doCommentSocketDisconnect,
doCommentList,
doSuperChatList,
doResolveUris,

View file

@ -17,8 +17,6 @@ type Props = {
uri: string,
claim: ?StreamClaim,
embed?: boolean,
doCommentSocketConnect: (string, string) => void,
doCommentSocketDisconnect: (string) => void,
doCommentList: (string, string, number, number) => void,
comments: Array<Comment>,
pinnedComments: Array<Comment>,
@ -39,8 +37,6 @@ export default function LivestreamComments(props: Props) {
claim,
uri,
embed,
doCommentSocketConnect,
doCommentSocketDisconnect,
comments: commentsByChronologicalOrder,
pinnedComments,
doCommentList,
@ -99,15 +95,8 @@ export default function LivestreamComments(props: Props) {
if (claimId) {
doCommentList(uri, '', 1, 75);
doSuperChatList(uri);
doCommentSocketConnect(uri, claimId);
}
return () => {
if (claimId) {
doCommentSocketDisconnect(claimId);
}
};
}, [claimId, uri, doCommentList, doSuperChatList, doCommentSocketConnect, doCommentSocketDisconnect]);
}, [claimId, uri, doCommentList, doSuperChatList]);
// Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => {

View file

@ -3,19 +3,36 @@ import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
import React from 'react';
import FileTitleSection from 'component/fileTitleSection';
import { useIsMobile } from 'effects/use-screensize';
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
import classnames from 'classnames';
import { lazyImport } from 'util/lazyImport';
import LivestreamLink from 'component/livestreamLink';
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
type Props = {
uri: string,
claim: ?StreamClaim,
isLive: boolean,
chatDisabled: boolean,
hideComments: boolean,
release: any,
showLivestream: boolean,
showScheduledInfo: boolean,
isCurrentClaimLive: boolean,
activeStreamUri: boolean | string,
};
export default function LivestreamLayout(props: Props) {
const { claim, uri, isLive, chatDisabled } = props;
const {
claim,
uri,
hideComments,
release,
showLivestream,
showScheduledInfo,
isCurrentClaimLive,
activeStreamUri,
} = props;
const isMobile = useIsMobile();
if (!claim || !claim.signing_channel) {
@ -28,17 +45,24 @@ export default function LivestreamLayout(props: Props) {
return (
<>
<div className="section card-stack">
<div className="file-render file-render--video livestream">
<div
className={classnames('file-render file-render--video livestream', {
'file-render--scheduledLivestream': !showLivestream,
})}
>
<div className="file-viewer">
<iframe
src={`${LIVESTREAM_EMBED_URL}/${channelClaimId}?skin=odysee&autoplay=1`}
scrolling="no"
allowFullScreen
/>
{showLivestream && (
<iframe
src={`${LIVESTREAM_EMBED_URL}/${channelClaimId}?skin=odysee&autoplay=1`}
scrolling="no"
allowFullScreen
/>
)}
{showScheduledInfo && <LivestreamScheduledInfo release={release} />}
</div>
</div>
{Boolean(chatDisabled) && (
{hideComments && !showScheduledInfo && (
<div className="help--notice">
{channelName
? __('%channel% has disabled chat for this stream. Enjoy the stream!', { channel: channelName })
@ -46,7 +70,7 @@ export default function LivestreamLayout(props: Props) {
</div>
)}
{!isLive && (
{!activeStreamUri && !showScheduledInfo && !isCurrentClaimLive && (
<div className="help--notice">
{channelName
? __("%channelName% isn't live right now, but the chat is! Check back later to watch the stream.", {
@ -56,9 +80,11 @@ export default function LivestreamLayout(props: Props) {
</div>
)}
<React.Suspense fallback={null}>{isMobile && <LivestreamComments uri={uri} />}</React.Suspense>
{activeStreamUri && <LivestreamLink claimUri={activeStreamUri} />}
<FileTitleSection uri={uri} livestream isLive={isLive} />
<React.Suspense fallback={null}>{isMobile && !hideComments && <LivestreamComments uri={uri} />}</React.Suspense>
<FileTitleSection uri={uri} livestream isLive={showLivestream} />
</div>
</>
);

View file

@ -1,9 +1,5 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import LivestreamLink from './view';
const select = (state, props) => ({
channelClaim: makeSelectClaimForUri(props.uri)(state),
});
export default connect(select)(LivestreamLink);
export default connect()(LivestreamLink);

View file

@ -1,72 +1,30 @@
// @flow
import * as CS from 'constants/claim_search';
import React from 'react';
import Card from 'component/common/card';
import ClaimPreview from 'component/claimPreview';
import Lbry from 'lbry';
import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url';
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
type Props = {
channelClaim: ChannelClaim,
claimUri: string,
};
export default function LivestreamLink(props: Props) {
const { channelClaim } = props;
const { claimUri } = props;
const { push } = useHistory();
const [livestreamClaim, setLivestreamClaim] = React.useState(false);
const [isLivestreaming, setIsLivestreaming] = React.useState(false);
const livestreamChannelId = (channelClaim && channelClaim.claim_id) || ''; // TODO: fail in a safer way, probably
// TODO: pput this back when hubs claims_in_channel are fixed
const isChannelEmpty = !channelClaim || !channelClaim.meta;
// ||
// !channelClaim.meta.claims_in_channel;
React.useEffect(() => {
// Don't search empty channels
if (livestreamChannelId && !isChannelEmpty) {
Lbry.claim_search({
channel_ids: [livestreamChannelId],
page: 1,
page_size: 1,
no_totals: true,
has_no_source: true,
claim_type: ['stream'],
order_by: CS.ORDER_BY_NEW_VALUE,
})
.then((res) => {
if (res && res.items && res.items.length > 0) {
const claim = res.items[0];
// $FlowFixMe Too many Claim GenericClaim etc types.
setLivestreamClaim(claim);
}
})
.catch(() => {});
}
}, [livestreamChannelId, isChannelEmpty]);
React.useEffect(() => {
if (!livestreamClaim) return;
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLivestreaming(state));
}, [livestreamChannelId, setIsLivestreaming, livestreamClaim]);
if (!livestreamClaim || !isLivestreaming) {
return null;
}
// gonna pass the wrapper in so I don't have to rewrite the dmca/blocking logic in claimPreview.
const element = (props: { children: any }) => (
<Card
className="livestream__channel-link claim-preview__live"
title={__('Live stream in progress')}
onClick={() => {
push(formatLbryUrlForWeb(livestreamClaim.canonical_url));
push(formatLbryUrlForWeb(claimUri));
}}
>
{props.children}
</Card>
);
return <ClaimPreview uri={livestreamClaim.canonical_url} wrapperElement={element} type="inline" />;
return claimUri && <ClaimPreview uri={claimUri} wrapperElement={element} type="inline" />;
}

View file

@ -0,0 +1,4 @@
import { connect } from 'react-redux';
import LivestreamScheduledInfo from './view';
export default connect()(LivestreamScheduledInfo);

View file

@ -0,0 +1,49 @@
// @flow
import React, { useState, useEffect } from 'react';
import * as ICONS from 'constants/icons';
import Icon from 'component/common/icon';
import moment from 'moment';
import 'scss/component/livestream-scheduled-info.scss';
type Props = {
release: any,
};
export default function LivestreamScheduledInfo(props: Props) {
const { release } = props;
const [startDateFromNow, setStartDateFromNow] = useState(release.fromNow());
const [inPast, setInPast] = useState('pending');
useEffect(() => {
const calcTime = () => {
setStartDateFromNow(release.fromNow());
setInPast(release.isBefore(moment()));
};
calcTime();
const intervalId = setInterval(calcTime, 1000);
return () => {
clearInterval(intervalId);
};
}, [release]);
const startDate = release.format('MMMM Do, h:mm a');
return (
inPast !== 'pending' && (
<div className={'livestream-scheduled'}>
<Icon icon={ICONS.LIVESTREAM_SOLID} size={32} />
<p className={'livestream-scheduled__time'}>
{!inPast && (
<span>
<span>Live {startDateFromNow}</span>
<br />
<span className={'livestream-scheduled__date'}>{startDate}</span>
</span>
)}
{inPast && <span>{__('Starting Soon')}</span>}
</p>
</div>
)
);
}

View file

@ -29,6 +29,7 @@ type Props = {
needsYTAuth: boolean,
fetchAccessToken: () => void,
accessToken: string,
isLivestreamMode: boolean,
};
function PublishAdditionalOptions(props: Props) {
@ -39,6 +40,7 @@ function PublishAdditionalOptions(props: Props) {
otherLicenseDescription,
licenseUrl,
updatePublishForm,
isLivestreamMode,
// user,
// useLBRYUploader,
// needsYTAuth,
@ -154,7 +156,7 @@ function PublishAdditionalOptions(props: Props) {
)} */}
{/* @endif */}
<div className="section">
<PublishReleaseDate />
{!isLivestreamMode && <PublishReleaseDate />}
<FormField
label={__('Language')}

View file

@ -31,6 +31,7 @@ import { useHistory } from 'react-router';
import Spinner from 'component/spinner';
import { toHex } from 'util/hex';
import { LIVESTREAM_REPLAY_API } from 'constants/livestream';
import PublishStreamReleaseDate from 'component/publishStreamReleaseDate';
// @if TARGET='app'
import fs from 'fs';
@ -609,8 +610,12 @@ function PublishForm(props: Props) {
{!publishing && (
<div className={classnames({ 'card--disabled': formDisabled })}>
{isLivestreamMode && <Card className={'card--enable-overflow'} body={<PublishStreamReleaseDate />} />}
{mode !== PUBLISH_MODES.POST && <PublishDescription disabled={formDisabled} />}
<Card actions={<SelectThumbnail livestreamdData={livestreamData} />} />
<TagsSelect
suggestMature={!SIMPLE_SITE}
disableAutoFocus
@ -640,7 +645,8 @@ function PublishForm(props: Props) {
<PublishBid disabled={isStillEditing || formDisabled} />
{!isLivestreamMode && <PublishPrice disabled={formDisabled} />}
<PublishAdditionalOptions disabled={formDisabled} />
<PublishAdditionalOptions disabled={formDisabled} isLivestreamMode={isLivestreamMode} />
</div>
)}
<section>

View file

@ -19,18 +19,28 @@ type Props = {
releaseTime: ?number,
releaseTimeEdited: ?number,
updatePublishForm: ({}) => void,
allowDefault: ?boolean,
showNowBtn: ?boolean,
useMaxDate: ?boolean,
};
const PublishReleaseDate = (props: Props) => {
const { releaseTime, releaseTimeEdited, updatePublishForm } = props;
const maxDate = new Date();
const {
releaseTime,
releaseTimeEdited,
updatePublishForm,
allowDefault = true,
showNowBtn = true,
useMaxDate = true,
} = props;
const maxDate = useMaxDate ? new Date() : undefined;
const [date, setDate] = React.useState(releaseTime ? linuxTimestampToDate(releaseTime) : new Date());
const isNew = releaseTime === undefined;
const isEdit = !isNew;
const isEdit = !isNew || allowDefault === false;
const showEditBtn = isNew && releaseTimeEdited === undefined;
const showDefaultBtn = isNew && releaseTimeEdited !== undefined;
const showEditBtn = isNew && releaseTimeEdited === undefined && allowDefault !== false;
const showDefaultBtn = isNew && releaseTimeEdited !== undefined && allowDefault !== false;
const showDatePicker = isEdit || releaseTimeEdited !== undefined;
const onDateTimePickerChanged = (value) => {
@ -108,7 +118,7 @@ const PublishReleaseDate = (props: Props) => {
onClick={() => newDate(RESET_TO_ORIGINAL)}
/>
)}
{showDatePicker && (
{showDatePicker && showNowBtn && (
<Button
button="link"
label={__('Now')}

View file

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import { makeSelectPublishFormValue } from 'redux/selectors/publish';
import { doUpdatePublishForm } from 'redux/actions/publish';
import PublishStreamReleaseDate from './view';
const select = (state) => ({
releaseTime: makeSelectPublishFormValue('releaseTime')(state),
});
const perform = (dispatch) => ({
updatePublishForm: (value) => dispatch(doUpdatePublishForm(value)),
});
export default connect(select, perform)(PublishStreamReleaseDate);

View file

@ -0,0 +1,96 @@
// @flow
import React from 'react';
import { FormField } from 'component/common/form';
import DateTimePicker from 'react-datetime-picker';
import moment from 'moment';
function linuxTimestampToDate(linuxTimestamp: number) {
return new Date(linuxTimestamp * 1000);
}
function dateToLinuxTimestamp(date: Date) {
return Number(Math.round(date.getTime() / 1000));
}
type Props = {
releaseTime: ?number,
updatePublishForm: ({}) => void,
};
const PublishStreamReleaseDate = (props: Props) => {
const { releaseTime, updatePublishForm } = props;
const [date, setDate] = React.useState(releaseTime ? linuxTimestampToDate(releaseTime) : 'DEFAULT');
const [publishLater, setPublishLater] = React.useState(Boolean(releaseTime));
const handleToggle = () => {
const shouldPublishLater = !publishLater;
setPublishLater(shouldPublishLater);
onDateTimePickerChanged(
shouldPublishLater ? moment().add('1', 'hour').add('30', 'minutes').startOf('hour').toDate() : 'DEFAULT'
);
};
const onDateTimePickerChanged = (value) => {
if (value === 'DEFAULT') {
setDate(undefined);
updatePublishForm({ releaseTimeEdited: undefined });
} else {
setDate(value);
updatePublishForm({ releaseTimeEdited: dateToLinuxTimestamp(value) });
}
};
const helpText = !publishLater
? __(
'Confirmation process takes a few minutes, but then you can go live anytime. The stream is not shown anywhere until you are broadcasting.'
)
: __(
'Your scheduled streams will appear on your channel page and for your followers. Chat will not be active until 5 minutes before the start time.'
);
return (
<div className="">
<label htmlFor="date-picker-input">{__('When do you want to go live?')}</label>
<div className={'w-full flex flex-col mt-s md:mt-0 md:h-12 md:items-center md:flex-row'}>
<FormField
type="checkbox"
name="rightNow"
disabled={false}
onChange={handleToggle}
checked={!publishLater}
label={__('Anytime')}
/>
<div className={'md:ml-m mt-s md:mt-0'}>
<FormField
type="checkbox"
name="rightNow"
disabled={false}
onChange={handleToggle}
checked={publishLater}
label={__('Scheduled Time')}
/>
</div>
{publishLater && (
<div className="form-field-date-picker mb-0 controls md:ml-m">
<DateTimePicker
className="date-picker-input w-full md:w-auto mt-s md:mt-0"
calendarClassName="form-field-calendar"
onChange={onDateTimePickerChanged}
value={date}
format="y-MM-dd h:mm a"
disableClock
clearIcon={null}
minDate={moment().add('30', 'minutes').toDate()}
/>
</div>
)}
</div>
<p className={'form-field__hint mt-m'}>{helpText}</p>
</div>
);
};
export default PublishStreamReleaseDate;

View file

@ -0,0 +1,4 @@
import { connect } from 'react-redux';
import ScheduledStreams from './view';
export default connect()(ScheduledStreams);

View file

@ -0,0 +1,95 @@
// @flow
import React from 'react';
import * as CS from 'constants/claim_search';
import moment from 'moment';
import * as ICONS from 'constants/icons';
import { useIsMediumScreen, useIsLargeScreen } from 'effects/use-screensize';
import ClaimListDiscover from 'component/claimListDiscover';
import Button from 'component/button';
import { LIVESTREAM_UPCOMING_BUFFER } from 'constants/livestream';
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
type Props = {
channelIds: Array<string>,
tileLayout: boolean,
liveUris: Array<string>,
limitClaimsPerChannel?: number,
};
const ScheduledStreams = (props: Props) => {
const { channelIds, tileLayout, liveUris = [], limitClaimsPerChannel } = props;
const isMediumScreen = useIsMediumScreen();
const isLargeScreen = useIsLargeScreen();
const [totalUpcomingLivestreams, setTotalUpcomingLivestreams] = React.useState(0);
const [showAllUpcoming, setShowAllUpcoming] = React.useState(false);
const showUpcomingLivestreams = totalUpcomingLivestreams > 0;
const upcomingMax = React.useMemo(() => {
if (showAllUpcoming) return 50;
if (isLargeScreen) return 6;
if (isMediumScreen) return 3;
return 4;
}, [showAllUpcoming, isMediumScreen, isLargeScreen]);
const loadedCallback = (total) => {
setTotalUpcomingLivestreams(total);
};
return (
<ClaimListDiscoverContext.Provider value={{ listingType: 'UPCOMING' }}>
<div className={'mb-xl'} style={{ display: showUpcomingLivestreams ? 'block' : 'none' }}>
<ClaimListDiscover
useSkeletonScreen={false}
channelIds={channelIds}
limitClaimsPerChannel={limitClaimsPerChannel}
pageSize={50}
streamType={'all'}
hasNoSource
orderBy={CS.ORDER_BY_NEW_ASC}
tileLayout={tileLayout}
releaseTime={`>${moment().subtract(LIVESTREAM_UPCOMING_BUFFER, 'minutes').startOf('minute').unix()}`}
hideAdvancedFilter
hideFilters
infiniteScroll={false}
showNoSourceClaims
hideLayoutButton
header={__('Upcoming Livestreams')}
maxClaimRender={upcomingMax}
excludeUris={liveUris}
loadedCallback={loadedCallback}
/>
{totalUpcomingLivestreams > upcomingMax && !showAllUpcoming && (
<div className="livestream-list--view-more">
<Button
label={__('Show more upcoming livestreams')}
button="link"
iconRight={ICONS.ARROW_RIGHT}
className="claim-grid__title--secondary"
onClick={() => {
setShowAllUpcoming(true);
}}
/>
</div>
)}
{showAllUpcoming && (
<div className="livestream-list--view-more">
<Button
label={__('Show less upcoming livestreams')}
button="link"
iconRight={ICONS.ARROW_RIGHT}
className="claim-grid__title--secondary"
onClick={() => {
setShowAllUpcoming(false);
}}
/>
</div>
)}
</div>
</ClaimListDiscoverContext.Provider>
);
};
export default ScheduledStreams;

View file

@ -475,6 +475,10 @@ export const FETCH_ACTIVE_LIVESTREAMS_FAILED = 'FETCH_ACTIVE_LIVESTREAMS_FAILED'
export const FETCH_ACTIVE_LIVESTREAMS_SKIPPED = 'FETCH_ACTIVE_LIVESTREAMS_SKIPPED';
export const FETCH_ACTIVE_LIVESTREAMS_COMPLETED = 'FETCH_ACTIVE_LIVESTREAMS_COMPLETED';
export const FETCH_ACTIVE_LIVESTREAM_FAILED = 'FETCH_ACTIVE_LIVESTREAMS_FAILED';
export const FETCH_ACTIVE_LIVESTREAM_COMPLETED = 'FETCH_ACTIVE_LIVESTREAM_COMPLETED';
export const FETCH_ACTIVE_LIVESTREAM_FINISHED = 'FETCH_ACTIVE_LIVESTREAM_FINISHED';
// Blacklist
export const FETCH_BLACK_LISTED_CONTENT_STARTED = 'FETCH_BLACK_LISTED_CONTENT_STARTED';
export const FETCH_BLACK_LISTED_CONTENT_COMPLETED = 'FETCH_BLACK_LISTED_CONTENT_COMPLETED';

View file

@ -29,10 +29,17 @@ export const FRESH_TYPES = [FRESH_DEFAULT, FRESH_DAY, FRESH_WEEK, FRESH_MONTH, F
export const ORDER_BY_TRENDING = 'trending';
export const ORDER_BY_TRENDING_VALUE = ['trending_group', 'trending_mixed'];
export const ORDER_BY_TOP = 'top';
export const ORDER_BY_TOP_VALUE = ['effective_amount'];
export const ORDER_BY_NEW = 'new';
export const ORDER_BY_NEW_VALUE = ['release_time'];
export const ORDER_BY_NEW_ASC = 'new_asc';
export const ORDER_BY_NEW_ASC_VALUE = ['^release_time'];
// @note: These are used to build the default controls available on claim listings.
export const ORDER_BY_TYPES = [ORDER_BY_TRENDING, ORDER_BY_NEW, ORDER_BY_TOP];
export const DURATION_SHORT = 'short';

View file

@ -5,3 +5,7 @@ export const LIVESTREAM_RTMP_URL = 'rtmp://stream.odysee.com/live';
export const LIVESTREAM_KILL = 'https://api.stream.odysee.com/stream/kill';
export const MAX_LIVESTREAM_COMMENTS = 50;
export const LIVESTREAM_STARTS_SOON_BUFFER = 5;
export const LIVESTREAM_STARTED_RECENTLY_BUFFER = 15;
export const LIVESTREAM_UPCOMING_BUFFER = 35;

View file

@ -142,6 +142,8 @@ const ModalPublishPreview = (props: Props) => {
);
}
const releasesInFuture = releaseTimeEdited && moment(releaseTimeEdited * 1000).isAfter();
const txFee = previewResponse ? previewResponse['total_fee'] : null;
// $FlowFixMe add outputs[0] etc to PublishResponse type
const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available;
@ -153,7 +155,7 @@ const ModalPublishPreview = (props: Props) => {
modalTitle = __('Confirm Edit');
}
} else if (livestream) {
modalTitle = __('Create Livestream');
modalTitle = releasesInFuture ? __('Schedule Livestream') : __('Create Livestream');
} else {
modalTitle = __('Confirm Upload');
}
@ -177,6 +179,8 @@ const ModalPublishPreview = (props: Props) => {
}
}
const releaseDateText = releasesInFuture ? __('Scheduled for') : __('Release date');
const descriptionValue = description ? (
<div className="media__info-text-preview">
<MarkdownPreview content={description} simpleLinks />
@ -250,7 +254,7 @@ const ModalPublishPreview = (props: Props) => {
{createRow(__('Deposit'), depositValue)}
{createRow(__('Price'), priceValue)}
{createRow(__('Language'), language)}
{releaseTimeEdited && createRow(__('Release date'), releaseTimeStr(releaseTimeEdited))}
{releaseTimeEdited && createRow(releaseDateText, releaseTimeStr(releaseTimeEdited))}
{createRow(__('License'), licenseValue)}
{createRow(__('Tags'), tagsValue)}
</tbody>

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import * as SETTINGS from 'constants/settings';
import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams } from 'redux/selectors/livestream';
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
import { selectSubscriptions } from 'redux/selectors/subscriptions';
import { selectClientSetting } from 'redux/selectors/settings';
@ -11,6 +11,7 @@ const select = (state) => ({
subscribedChannels: selectSubscriptions(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
activeLivestreams: selectActiveLivestreams(state),
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
});
export default connect(select, {

View file

@ -11,52 +11,71 @@ import Button from 'component/button';
import Icon from 'component/common/icon';
import { splitBySeparator } from 'util/lbryURI';
import { getLivestreamUris } from 'util/livestream';
import ScheduledStreams from 'component/scheduledStreams';
type Props = {
subscribedChannels: Array<Subscription>,
tileLayout: boolean,
activeLivestreams: ?LivestreamInfo,
doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean,
};
function ChannelsFollowingPage(props: Props) {
const { subscribedChannels, tileLayout, activeLivestreams, doFetchActiveLivestreams } = props;
const {
subscribedChannels,
tileLayout,
activeLivestreams,
doFetchActiveLivestreams,
fetchingActiveLivestreams,
} = props;
const hasSubsribedChannels = subscribedChannels.length > 0;
const hasSubscribedChannels = subscribedChannels.length > 0;
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
React.useEffect(() => {
doFetchActiveLivestreams();
}, []);
return !hasSubsribedChannels ? (
return !hasSubscribedChannels ? (
<ChannelsFollowingDiscoverPage />
) : (
<Page noFooter fullWidthPage={tileLayout}>
<ClaimListDiscover
prefixUris={getLivestreamUris(activeLivestreams, channelIds)}
hideAdvancedFilter={SIMPLE_SITE}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
tileLayout={tileLayout}
headerLabel={
<span>
<Icon icon={ICONS.SUBSCRIBE} size={10} />
{__('Following')}
</span>
}
defaultOrderBy={CS.ORDER_BY_NEW}
channelIds={channelIds}
meta={
<Button
icon={ICONS.SEARCH}
button="secondary"
label={__('Discover Channels')}
navigate={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
{!fetchingActiveLivestreams && (
<>
<ScheduledStreams
channelIds={channelIds}
tileLayout={tileLayout}
liveUris={getLivestreamUris(activeLivestreams, channelIds)}
limitClaimsPerChannel={2}
/>
}
showNoSourceClaims={ENABLE_NO_SOURCE_CLAIMS}
hasSource
/>
<ClaimListDiscover
prefixUris={getLivestreamUris(activeLivestreams, channelIds)}
hideAdvancedFilter={SIMPLE_SITE}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
tileLayout={tileLayout}
headerLabel={
<span>
<Icon icon={ICONS.SUBSCRIBE} size={10} />
{__('Following')}
</span>
}
defaultOrderBy={CS.ORDER_BY_NEW}
channelIds={channelIds}
meta={
<Button
icon={ICONS.SEARCH}
button="secondary"
label={__('Discover Channels')}
navigate={`/$/${PAGES.CHANNELS_FOLLOWING_DISCOVER}`}
/>
}
showNoSourceClaims={ENABLE_NO_SOURCE_CLAIMS}
hasSource
/>
</>
)}
</Page>
);
}

View file

@ -39,7 +39,7 @@ type Props = {
isAuthenticated: boolean,
tileLayout: boolean,
activeLivestreams: ?LivestreamInfo,
doFetchActiveLivestreams: (orderBy?: Array<string>, pageSize?: number, forceFetch?: boolean) => void,
doFetchActiveLivestreams: (orderBy?: Array<string>) => void,
};
function DiscoverPage(props: Props) {
@ -307,7 +307,7 @@ function DiscoverPage(props: Props) {
}
}
})();
}, []);
}, [isAuthenticated]);
// Sync liveSection --> liveSectionStore
React.useEffect(() => {

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams } from 'redux/selectors/livestream';
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectSubscriptions } from 'redux/selectors/subscriptions';
@ -15,6 +15,7 @@ const select = (state) => ({
showNsfw: selectShowMatureContent(state),
homepageData: selectHomepageData(state),
activeLivestreams: selectActiveLivestreams(state),
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
});
const perform = (dispatch) => ({

View file

@ -13,6 +13,8 @@ import WaitUntilOnPage from 'component/common/wait-until-on-page';
import { useIsLargeScreen } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage';
import { getLivestreamUris } from 'util/livestream';
import ScheduledStreams from 'component/scheduledStreams';
import { splitBySeparator } from 'util/lbryURI';
// @if TARGET='web'
import Pixel from 'web/component/pixel';
@ -27,6 +29,7 @@ type Props = {
homepageData: any,
activeLivestreams: any,
doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean,
};
function HomePage(props: Props) {
@ -38,12 +41,15 @@ function HomePage(props: Props) {
homepageData,
activeLivestreams,
doFetchActiveLivestreams,
fetchingActiveLivestreams,
} = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
const showIndividualTags = showPersonalizedTags && followedTags.length < 5;
const isLargeScreen = useIsLargeScreen();
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
const rowData: Array<RowDataItem> = GetLinksData(
homepageData,
isLargeScreen,
@ -254,14 +260,28 @@ function HomePage(props: Props) {
</p>
</div>
)}
{/* @if TARGET='web' */}
{SIMPLE_SITE && <Meme />}
<Ads type="homepage" />
{/* @endif */}
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
// add pins here
return getRowElements(title, route, link, icon, help, options, index, pinUrls);
})}
{!fetchingActiveLivestreams && (
<>
{authenticated && channelIds.length > 0 && (
<ScheduledStreams
channelIds={channelIds}
tileLayout
liveUris={getLivestreamUris(activeLivestreams, channelIds)}
limitClaimsPerChannel={2}
/>
)}
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
// add pins here
return getRowElements(title, route, link, icon, help, options, index, pinUrls);
})}
</>
)}
{/* @if TARGET='web' */}
<Pixel type={'retargeting'} />
{/* @endif */}

View file

@ -4,16 +4,25 @@ import { doSetPlayingUri } from 'redux/actions/content';
import { doUserSetReferrer } from 'redux/actions/user';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { getChannelIdFromClaim } from 'util/claim';
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
import { doFetchActiveLivestream } from 'redux/actions/livestream';
import LivestreamPage from './view';
const select = (state, props) => ({
isAuthenticated: selectUserVerifiedEmail(state),
channelClaimId: getChannelIdFromClaim(selectClaimForUri(state, props.uri)),
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
currentChannelStatus: selectCurrentChannelStatus(state),
});
export default connect(select, {
const perform = {
doSetPlayingUri,
doUserSetReferrer,
})(LivestreamPage);
doCommentSocketConnect,
doCommentSocketDisconnect,
doFetchActiveLivestream,
};
export default connect(select, perform)(LivestreamPage);

View file

@ -4,7 +4,8 @@ import { lazyImport } from 'util/lazyImport';
import Page from 'component/page';
import LivestreamLayout from 'component/livestreamLayout';
import analytics from 'analytics';
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
import moment from 'moment';
import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream';
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
@ -16,46 +17,135 @@ type Props = {
doUserSetReferrer: (string) => void,
channelClaimId: ?string,
chatDisabled: boolean,
doCommentSocketConnect: (string, string) => void,
doCommentSocketDisconnect: (string) => void,
doFetchActiveLivestream: (string) => void,
currentChannelStatus: LivestreamChannelStatus,
};
export default function LivestreamPage(props: Props) {
const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer, channelClaimId, chatDisabled } = props;
const [isLive, setIsLive] = React.useState('pending');
const livestreamChannelId = channelClaimId;
const {
uri,
claim,
doSetPlayingUri,
isAuthenticated,
doUserSetReferrer,
channelClaimId,
chatDisabled,
doCommentSocketConnect,
doCommentSocketDisconnect,
doFetchActiveLivestream,
currentChannelStatus,
} = props;
React.useEffect(() => {
// TODO: This should not be needed one we unify the livestream player (?)
analytics.playerLoadedEvent('livestream', false);
}, []);
const claimId = claim && claim.claim_id;
// Establish web socket connection for viewer count.
React.useEffect(() => {
if (!livestreamChannelId) {
setIsLive(false);
return;
if (claimId) {
doCommentSocketConnect(uri, claimId);
}
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLive(state));
}, [livestreamChannelId, setIsLive]);
return () => {
if (claimId) {
doCommentSocketDisconnect(claimId);
}
};
}, [claimId, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
const [isInitialized, setIsInitialized] = React.useState(false);
const [isChannelBroadcasting, setIsChannelBroadcasting] = React.useState(false);
const [isCurrentClaimLive, setIsCurrentClaimLive] = React.useState(false);
const livestreamChannelId = channelClaimId || '';
// Find out current channels status + active live claim.
React.useEffect(() => {
doFetchActiveLivestream(livestreamChannelId);
const intervalId = setInterval(() => doFetchActiveLivestream(livestreamChannelId), 30000);
return () => clearInterval(intervalId);
}, [livestreamChannelId, doFetchActiveLivestream]);
React.useEffect(() => {
const initialized = currentChannelStatus.channelId === livestreamChannelId;
setIsInitialized(initialized);
if (initialized) {
setIsChannelBroadcasting(currentChannelStatus.isBroadcasting);
setIsCurrentClaimLive(currentChannelStatus.liveClaim.claimId === claimId);
}
}, [currentChannelStatus, livestreamChannelId, claimId]);
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
React.useEffect(() => {
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? currentChannelStatus.liveClaim.claimUri : false);
}, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps
// $FlowFixMe
const release = moment.unix(claim.value.release_time);
const [showLivestream, setShowLivestream] = React.useState(false);
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
const [hideComments, setHideComments] = React.useState(false);
React.useEffect(() => {
if (!isInitialized) return;
const claimReleaseInFuture = () => release.isAfter();
const claimReleaseInPast = () => release.isBefore();
const claimReleaseStartingSoon = () =>
release.isBetween(moment(), moment().add(LIVESTREAM_STARTS_SOON_BUFFER, 'minutes'));
const claimReleaseStartedRecently = () =>
release.isBetween(moment().subtract(LIVESTREAM_STARTED_RECENTLY_BUFFER, 'minutes'), moment());
const checkShowLivestream = () =>
isChannelBroadcasting && isCurrentClaimLive && (claimReleaseInPast() || claimReleaseStartingSoon());
const checkShowScheduledInfo = () =>
(!isChannelBroadcasting && (claimReleaseInFuture() || claimReleaseStartedRecently())) ||
(isChannelBroadcasting &&
((!isCurrentClaimLive && (claimReleaseInFuture() || claimReleaseStartedRecently())) ||
(isCurrentClaimLive && claimReleaseInFuture() && !claimReleaseStartingSoon())));
const checkCommentsDisabled = () => chatDisabled || (claimReleaseInFuture() && !claimReleaseStartingSoon());
const calculateStreamReleaseState = () => {
setShowLivestream(checkShowLivestream());
setShowScheduledInfo(checkShowScheduledInfo());
setHideComments(checkCommentsDisabled());
};
calculateStreamReleaseState();
const intervalId = setInterval(calculateStreamReleaseState, 1000);
if (isCurrentClaimLive && claimReleaseInPast() && isChannelBroadcasting === true) {
clearInterval(intervalId);
}
return () => clearInterval(intervalId);
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
const stringifiedClaim = JSON.stringify(claim);
React.useEffect(() => {
if (uri && stringifiedClaim) {
const jsonClaim = JSON.parse(stringifiedClaim);
if (jsonClaim) {
const { txid, nout, claim_id: claimId } = jsonClaim;
const outpoint = `${txid}:${nout}`;
analytics.apiLogView(uri, outpoint, claimId);
}
if (!isAuthenticated) {
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
if (uri) {
doUserSetReferrer(uri.replace('lbry://', ''));
doUserSetReferrer(uri.replace('lbry://', '')); //
}
}
}
}, [uri, stringifiedClaim, isAuthenticated]);
}, [uri, stringifiedClaim, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect(() => {
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
@ -64,22 +154,31 @@ export default function LivestreamPage(props: Props) {
}, [doSetPlayingUri]);
return (
isLive !== 'pending' && (
<Page
className="file-page"
noFooter
livestream
chatDisabled={chatDisabled}
rightSide={
!chatDisabled && (
<React.Suspense fallback={null}>
<LivestreamComments uri={uri} />
</React.Suspense>
)
}
>
<LivestreamLayout uri={uri} isLive={isLive} />
</Page>
)
<Page
className="file-page"
noFooter
livestream
chatDisabled={hideComments}
rightSide={
!hideComments &&
isInitialized && (
<React.Suspense fallback={null}>
<LivestreamComments uri={uri} />
</React.Suspense>
)
}
>
{isInitialized && (
<LivestreamLayout
uri={uri}
hideComments={hideComments}
release={release}
isCurrentClaimLive={isCurrentClaimLive}
showLivestream={showLivestream}
showScheduledInfo={showScheduledInfo}
activeStreamUri={activeStreamUri}
/>
)}
</Page>
);
}

View file

@ -25,7 +25,7 @@ type Props = {
pendingClaims: Array<Claim>,
doNewLivestream: (string) => void,
fetchNoSourceClaims: (string) => void,
myLivestreamClaims: Array<Claim>,
myLivestreamClaims: Array<StreamClaim>,
fetchingLivestreams: boolean,
channelId: ?string,
channelName: ?string,
@ -67,7 +67,7 @@ export default function LivestreamSetupPage(props: Props) {
`Create a Livestream by first submitting your livestream details and waiting for approval confirmation. This can be done well in advance and will take a few minutes.`
)}{' '}
{__(
`The livestream will not be visible on your channel page until you are live, but you can share the URL in advance.`
`Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.`
)}{' '}
{__(
`Once the your livestream is confirmed, configure your streaming software (OBS, Restream, etc) and input the server URL along with the stream key in it.`
@ -85,6 +85,7 @@ export default function LivestreamSetupPage(props: Props) {
<p>
{__(`If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.`)}
</p>
<p>{__(`For streaming from your mobile device, we recommend PRISM Live Studio from the app store.`)}</p>
<p>
{__(
`After your stream:\nClick the Update button on the content page. This will allow you to select a replay or upload your own edited MP4. Replays are limited to 4 hours and may take a few minutes to show (use the Check For Replays button).`
@ -130,6 +131,42 @@ export default function LivestreamSetupPage(props: Props) {
};
}, [channelId, pendingLength, fetchNoSourceClaims]);
const filterPending = (claims: Array<StreamClaim>) => {
return claims.filter((claim) => {
return !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url);
});
};
const upcomingStreams = filterPending(myLivestreamClaims).filter((claim) => {
return Number(claim.value.release_time) * 1000 > Date.now();
});
const pastStreams = filterPending(myLivestreamClaims).filter((claim) => {
return Number(claim.value.release_time) * 1000 <= Date.now();
});
type HeaderProps = {
title: string,
hideBtn?: boolean,
};
const ListHeader = (props: HeaderProps) => {
const { title, hideBtn = false } = props;
return (
<div className={'w-full flex items-center justify-between'}>
<span>{title}</span>
{!hideBtn && (
<Button
button="primary"
iconRight={ICONS.ADD}
onClick={() => doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`)}
label={__('Create or Schedule a New Stream')}
/>
)}
</div>
);
};
return (
<Page>
{fetchingChannels && (
@ -150,10 +187,11 @@ export default function LivestreamSetupPage(props: Props) {
/>
)}
{!fetchingChannels && (
<div className="section__actions--between">
<ChannelSelector hideAnon />
<Button button="link" onClick={() => setShowHelp(!showHelp)} label={__('How does this work?')} />
</div>
<>
<div className="section__actions--between">
<ChannelSelector hideAnon />
</div>
</>
)}
{fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
@ -164,14 +202,14 @@ export default function LivestreamSetupPage(props: Props) {
<div className="card-stack">
{!fetchingChannels && channelId && (
<>
{showHelp && (
<Card
titleActions={<Button button="close" icon={ICONS.REMOVE} onClick={() => setShowHelp(false)} />}
title={__('Go Live on Odysee')}
subtitle={__(`You're invited to try out our new livestreaming service while in beta!`)}
actions={helpText}
/>
)}
<Card
titleActions={
<Button button="close" icon={showHelp ? ICONS.UP : ICONS.DOWN} onClick={() => setShowHelp(!showHelp)} />
}
title={__('Go Live on Odysee')}
subtitle={<>{__(`You're invited to try out our new livestreaming service while in beta!`)} </>}
actions={showHelp && helpText}
/>
{streamKey && totalLivestreamClaims.length > 0 && (
<Card
className="section"
@ -189,7 +227,7 @@ export default function LivestreamSetupPage(props: Props) {
primaryButton
enableInputMask
name="livestream-key"
label={__('Stream key')}
label={__('Stream key (can be reused)')}
copyable={streamKey}
snackMessage={__('Copied stream key.')}
/>
@ -203,37 +241,45 @@ export default function LivestreamSetupPage(props: Props) {
{Boolean(pendingClaims.length) && (
<div className="section">
<ClaimList
header={__('Your pending livestream uploads')}
header={__('Your pending livestreams uploads')}
uris={pendingClaims.map((claim) => claim.permanent_url)}
/>
</div>
)}
{Boolean(myLivestreamClaims.length) && (
<div className="section">
<ClaimList
header={__('Your livestream uploads')}
empty={
<I18nMessage
tokens={{
check_again: (
<Button
button="link"
onClick={() => fetchNoSourceClaims(channelId)}
label={__('Check again')}
/>
),
}}
>
Nothing here yet. %check_again%
</I18nMessage>
}
uris={myLivestreamClaims
.filter(
(claim) => !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url)
)
.map((claim) => claim.permanent_url)}
/>
</div>
<>
{Boolean(upcomingStreams.length) && (
<div className="section">
<ClaimList
header={<ListHeader title={__('Your Scheduled Livestreams')} />}
uris={upcomingStreams.map((claim) => claim.permanent_url)}
/>
</div>
)}
<div className="section">
<ClaimList
header={
<ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} />
}
empty={
<I18nMessage
tokens={{
check_again: (
<Button
button="link"
onClick={() => fetchNoSourceClaims(channelId)}
label={__('Check again')}
/>
),
}}
>
Nothing here yet. %check_again%
</I18nMessage>
}
uris={pastStreams.map((claim) => claim.permanent_url)}
/>
</div>
</>
)}
</>
) : (

View file

@ -25,6 +25,7 @@ import * as COLLECTIONS_CONSTS from 'constants/collections';
import { push } from 'connected-react-router';
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { selectBlacklistedOutpointMap } from 'lbryinc';
import { doAnalyticsView } from 'redux/actions/app';
import ShowPage from './view';
const select = (state, props) => {
@ -96,6 +97,7 @@ const perform = (dispatch) => ({
dispatch(push(`/$/${PAGES.UPLOAD}`));
},
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })),
doAnalyticsView: (uri) => dispatch(doAnalyticsView(uri)),
});
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 } from 'react';
import React, { useEffect, useState } from 'react';
import { lazyImport } from 'util/lazyImport';
import { Redirect, useHistory } from 'react-router-dom';
import Spinner from 'component/spinner';
@ -39,6 +39,7 @@ type Props = {
collectionUrls: Array<string>,
isResolvingCollection: boolean,
fetchCollectionItems: (string) => void,
doAnalyticsView: (string) => void,
};
function ShowPage(props: Props) {
@ -59,6 +60,7 @@ function ShowPage(props: Props) {
collection,
collectionUrls,
isResolvingCollection,
doAnalyticsView,
} = props;
const { search } = location;
@ -73,6 +75,8 @@ function ShowPage(props: Props) {
const isCollection = claim && claim.value_type === 'collection';
const resolvedCollection = collection && collection.id; // not null
const showLiveStream = isLivestream && ENABLE_NO_SOURCE_CLAIMS;
// changed this from 'isCollection' to resolve strangers' collections.
React.useEffect(() => {
if (collectionId && !resolvedCollection) {
@ -113,6 +117,16 @@ function ShowPage(props: Props) {
}
}, [resolveUri, 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
// Also need to add repost_url to the Claim type for flow
@ -201,8 +215,8 @@ function ShowPage(props: Props) {
/>
</Page>
);
} else if (isLivestream && ENABLE_NO_SOURCE_CLAIMS) {
innerContent = <LivestreamPage uri={uri} />;
} else if (showLiveStream) {
innerContent = <LivestreamPage uri={uri} claim={claim} />;
} else {
innerContent = <FilePage uri={uri} location={location} />;
}

View file

@ -1,7 +1,8 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { doClaimSearch } from 'redux/actions/claims';
import { LIVESTREAM_LIVE_API } from 'constants/livestream';
import { LIVESTREAM_LIVE_API, LIVESTREAM_STARTS_SOON_BUFFER } from 'constants/livestream';
import moment from 'moment';
export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({
@ -35,88 +36,179 @@ export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dis
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
export const doFetchActiveLivestreams = (
orderBy: Array<string> = ['release_time'],
pageSize: number = 50,
forceFetch: boolean = false
) => {
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 () => {
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) => {
const response = await fetch(`${LIVESTREAM_LIVE_API}/${channelId}`);
const json = await response.json();
if (!(json.data && json.data.live)) throw new Error();
return transformLivestreamData([json.data]);
};
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>) => {
return doClaimSearch({
page: 1,
page_size: 50,
has_no_source: true,
channel_ids: channelIds,
claim_type: ['stream'],
order_by: ['^release_time'],
release_time: `>${moment().subtract(5, 'minutes').unix()}`,
limit_claims_per_channel: 1,
no_totals: true,
});
};
const fetchMostRecentLivestreamClaims = (channelIds: Array<string>, orderBy: Array<string> = ['release_time']) => {
return doClaimSearch({
page: 1,
page_size: 50,
has_no_source: true,
channel_ids: channelIds,
claim_type: ['stream'],
order_by: orderBy,
release_time: `<${moment().unix()}`,
limit_claims_per_channel: 2,
no_totals: 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>, orderBy: Array<string>, liveChannels: any, dispatch) => {
// @Note: This can likely be simplified down to one query, but first we'll need to address the query limit / pagination issue.
// Find the two most recent claims for the channels that are actively broadcasting a stream.
const mostRecentClaims = await dispatch(fetchMostRecentLivestreamClaims(channelIDs, orderBy));
// Find the first upcoming claim (if one exists) for each channel that's actively broadcasting a stream.
const upcomingClaims = await dispatch(fetchUpcomingLivestreamClaims(channelIDs));
// Filter out any of those claims that aren't scheduled to start within the configured "soon" buffer time (ex. next 5 min).
const startingSoonClaims = filterUpcomingLiveStreamClaims(upcomingClaims);
// Reduce the claim list to one "live" claim per channel, based on how close each claim's
// release time is to the time the channels stream started.
const allClaims = Object.assign({}, mostRecentClaims, startingSoonClaims);
return determineLiveClaim(allClaims, liveChannels);
};
export const doFetchActiveLivestream = (channelId: string) => {
return async (dispatch: Dispatch) => {
try {
const liveChannel = await fetchLiveChannel(channelId);
const currentlyLiveClaims = await findActiveStreams([channelId], ['release_time'], liveChannel, dispatch);
const liveClaim = currentlyLiveClaims[channelId];
dispatch({
type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_COMPLETED,
data: { liveClaim: { claimId: liveClaim.stream.claim_id, claimUri: liveClaim.stream.canonical_url } },
});
} catch (err) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_FAILED });
} finally {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_FINISHED, data: { channelId } });
}
};
};
export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time']) => {
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 = { page_size: pageSize, order_by: orderBy };
const nextOptions = { order_by: orderBy };
const sameOptions = JSON.stringify(prevOptions) === JSON.stringify(nextOptions);
if (!forceFetch && sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
if (sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
return;
}
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
fetch(LIVESTREAM_LIVE_API)
.then((res) => res.json())
.then((res) => {
if (!res.data) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
return;
}
try {
const liveChannels = await fetchLiveChannels();
const liveChannelIds = Object.keys(liveChannels);
const activeLivestreams: LivestreamInfo = res.data.reduce((acc, curr) => {
acc[curr.claimId] = {
live: curr.live,
viewCount: curr.viewCount,
creatorId: curr.claimId,
};
return acc;
}, {});
const currentlyLiveClaims = await findActiveStreams(liveChannelIds, nextOptions.order_by, liveChannels, dispatch);
Object.values(currentlyLiveClaims).forEach((claim: any) => {
const channelId = claim.stream.signing_channel.claim_id;
dispatch(
// ** Creators can have multiple livestream claims (each with unique
// chat), and all of them will play the same stream when creator goes
// live. The UI usually just wants to report the latest claim, so we
// query that store it in `latestClaimUri`.
doClaimSearch({
page: 1,
page_size: nextOptions.page_size,
has_no_source: true,
channel_ids: Object.keys(activeLivestreams),
claim_type: ['stream'],
order_by: nextOptions.order_by, // **
limit_claims_per_channel: 1, // **
no_totals: true,
})
)
.then((resolveInfo) => {
Object.values(resolveInfo).forEach((x) => {
// $FlowFixMe
const channelId = x.stream.signing_channel.claim_id;
activeLivestreams[channelId] = {
...activeLivestreams[channelId],
// $FlowFixMe
latestClaimId: x.stream.claim_id,
// $FlowFixMe
latestClaimUri: x.stream.canonical_url,
};
});
dispatch({
type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_COMPLETED,
data: {
activeLivestreams,
activeLivestreamsLastFetchedDate: now,
activeLivestreamsLastFetchedOptions: nextOptions,
},
});
})
.catch(() => {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
});
})
.catch((err) => {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
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

@ -1,14 +1,28 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils';
const currentChannelStatus: LivestreamChannelStatus = {
channelId: null,
isBroadcasting: false,
liveClaim: {
claimId: null,
claimUri: null,
},
};
const defaultState: LivestreamState = {
fetchingById: {},
viewersById: {},
fetchingActiveLivestreams: false,
fetchingActiveLivestreams: 'pending',
activeLivestreams: null,
activeLivestreamsLastFetchedDate: 0,
activeLivestreamsLastFetchedOptions: {},
currentChannelStatus: {
...currentChannelStatus,
},
};
export default handleActions(
@ -56,6 +70,25 @@ export default handleActions(
activeLivestreamsLastFetchedOptions,
};
},
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_COMPLETED]: (state: LivestreamState, action: any) => {
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, {
isBroadcasting: true,
liveClaim: action.data.liveClaim,
});
return { ...state, currentChannelStatus };
},
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_FAILED]: (state: LivestreamState) => {
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, {
isBroadcasting: false,
liveClaim: { claimId: null, claimUri: null },
});
return { ...state, currentChannelStatus };
},
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_FINISHED]: (state: LivestreamState, action: any) => {
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, { channelId: action.data.channelId });
return { ...state, currentChannelStatus };
},
},
defaultState
);

View file

@ -57,7 +57,11 @@ export const selectIsActiveLivestreamForUri = createCachedSelector(
}
const activeLivestreamValues = Object.values(activeLivestreams);
// $FlowFixMe - unable to resolve latestClaimUri
return activeLivestreamValues.some((v) => v.latestClaimUri === uri);
// $FlowFixMe - unable to resolve claimUri
return activeLivestreamValues.some((v) => v.claimUri === uri);
}
)((state, uri) => String(uri));
export const selectFetchingActiveLivestreams = (state: State) => selectState(state).fetchingActiveLivestreams;
export const selectCurrentChannelStatus = (state: State) => selectState(state).currentChannelStatus;

View file

@ -85,6 +85,12 @@
}
}
.file-render--scheduledLivestream {
background-image: url('//spee.ch/odysee-streaming-png:2.png?quality=80&height=960&width=1920');
background-size: cover;
margin-top: var(--spacing-m);
}
@keyframes fadeInFromBlack {
0% {
opacity: 1;

View file

@ -451,6 +451,11 @@ fieldset-group {
font-size: var(--font-xsmall);
}
.form-field__hint {
font-size: var(--font-xsmall);
color: var(--color-input-label);
}
.form-field__textarea-info {
display: flex;
flex-wrap: wrap;
@ -506,6 +511,7 @@ fieldset-section {
.form-field-date-picker {
margin-bottom: var(--spacing-l);
font-size: var(--font-base);
label {
display: block;

View file

@ -1,3 +1,78 @@
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.items-center {
align-items: center;
}
.w-full {
width: 100%;
}
.opacity-40 {
opacity: 0.4;
}
.h-12 {
height: 3rem;
}
.mt-s {
margin-top: var(--spacing-s);
}
.mt-m {
margin-top: var(--spacing-m);
}
.mb-m {
margin-bottom: var(--spacing-m);
}
.mb-xl {
margin-bottom: var(--spacing-xl);
}
.ml-m {
margin-left: var(--spacing-m);
}
.mr-m {
margin-right: var(--spacing-m);
}
.mb-0 {
margin-bottom: 0;
}
@media (min-width: $breakpoint-small) {
.md\:items-center {
align-items: center;
}
.md\:flex-col {
flex-direction: column;
}
.md\:ml-m {
margin-left: var(--spacing-m);
}
.md\:flex-row {
flex-direction: row;
}
.md\:w-auto {
width: auto;
}
.md\:mt-0 {
margin-top: 0;
}
.md\:h-12 {
height: 3rem;
}
}

View file

@ -0,0 +1,19 @@
.livestream-scheduled {
background-color: var(--color-black);
color: var(--color-white);
display: flex;
align-items: center;
position: absolute;
bottom: var(--spacing-m);
left: var(--spacing-m);
padding: var(--spacing-m);
border-radius: var(--border-radius);
}
.livestream-scheduled__time {
padding-left: var(--spacing-m);
line-height: 1;
}
.livestream-scheduled__date {
font-size: var(--font-xsmall);
}

View file

@ -13,12 +13,12 @@ export function getLivestreamUris(activeLivestreams: ?LivestreamInfo, channelIds
if (channelIds && channelIds.length > 0) {
// $FlowFixMe
values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.latestClaimUri));
values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.claimUri));
} else {
// $FlowFixMe
values = values.filter((v) => Boolean(v.latestClaimUri));
values = values.filter((v) => Boolean(v.claimUri));
}
// $FlowFixMe
return values.map((v) => v.latestClaimUri);
return values.map((v) => v.claimUri);
}

View file

@ -11215,6 +11215,11 @@ merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
merge-refs@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/merge-refs/-/merge-refs-1.0.0.tgz#388348bce22e623782c6df9d3c4fc55888276120"
integrity sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -13657,25 +13662,26 @@ react-confetti@^4.0.1:
dependencies:
tween-functions "^1.2.0"
react-date-picker@^8.1.0:
version "8.1.1"
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.1.1.tgz#1959608cd042c9bfcf2faa6d63a56e9ef6b17e2b"
integrity sha512-kFhn+uSJML+EuROvR6qLYU5G3wsxrdB2K1ugh1t6HjJCjphE6ot85jb8THWebqWEcQi07pLseU7ZFpzKDD3A6A==
react-date-picker@^8.3.3:
version "8.3.6"
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.6.tgz#446142bee5691aea66a2bac53313357aca561cd4"
integrity sha512-c1rThf0jSKROoSGLpUEPtcC8VE+XoVgqxh+ng9aLYQvjDMGWQBgoat6Qrj8nRVzvCPpdXV4jqiCB3z2vVVuseA==
dependencies:
"@types/react-calendar" "^3.0.0"
"@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0"
react-calendar "^3.3.1"
react-fit "^1.0.3"
update-input-width "^1.1.1"
update-input-width "^1.2.2"
react-datetime-picker@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.2.1.tgz#d3a9631bcba17bd0047e6424cff0dfe242d9cf0e"
integrity sha512-elybaAL7RJG7r0elYZze5/zQo1ds0v+v89tyZkzEShw+6I1EcveXwYPOMj3aq0k7D5kY/K+dC5dWYw0w4d9kmw==
react-datetime-picker@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.4.3.tgz#9163471f72b708185482b6b72cd259da03462f79"
integrity sha512-yuFmh3TJwDo3VnyQF6auRJoeYfFTUtyLsR292lWXieigp0ugKkQefUEzVybZQidiiUlCNK9UQgc37/igl7uBYA==
dependencies:
"@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0"
@ -13684,9 +13690,9 @@ react-datetime-picker@^3.2.1:
prop-types "^15.6.0"
react-calendar "^3.3.1"
react-clock "^3.0.0"
react-date-picker "^8.1.0"
react-date-picker "^8.3.3"
react-fit "^1.0.3"
react-time-picker "^4.2.0"
react-time-picker "^4.4.2"
react-dev-utils@^3.0.2, react-dev-utils@^3.1.0:
version "3.1.3"
@ -13914,19 +13920,20 @@ react-spring@^8.0.20, react-spring@^8.0.27:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
react-time-picker@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.2.1.tgz#b27f0bbc2e58534f20dbf10b14d0b8f3334fcb07"
integrity sha512-T0aEabJ3bz54l8LV3pdpB5lOZuO3pRIbry5STcUV58UndlrWLcHpdpvS1IC8JLNXhbLxzGs1MmpASb5k1ddlsg==
react-time-picker@^4.4.2:
version "4.4.4"
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.4.4.tgz#a67ca5fd88f51eac0919df802e416d9a25ad726a"
integrity sha512-WMdrpGnegug0871Do+SU1Fe91uZGmS6JUo1Yw7eLfU3VHMXCFj9sL9FAT6BuXe7lfILBbXq4tQQOqa/rLDASQg==
dependencies:
"@wojtekmaj/date-utils" "^1.0.0"
get-user-locale "^1.2.0"
make-event-props "^1.1.0"
merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0"
react-clock "^3.0.0"
react-fit "^1.0.3"
update-input-width "^1.1.1"
update-input-width "^1.2.2"
react-top-loading-bar@^2.0.1:
version "2.0.1"
@ -16705,10 +16712,10 @@ upath@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
update-input-width@^1.1.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.1.tgz#769d6182413590c3b50b52ffa9c65d79e2c17f95"
integrity sha512-zygDshqDb2C2/kgfoD423n5htv/3OBF7aTaz2u2zZy998EJki8njOHOeZjKEd8XSYeDziIX1JXfMsKaIRJeJ/Q==
update-input-width@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.2.tgz#9a6a35858ae8e66fbfe0304437b23a4934fc7d37"
integrity sha512-6QwD9ZVSXb96PxOZ01DU0DJTPwQGY7qBYgdniZKJN02Xzom2m+9J6EPxMbefskqtj4x78qbe5psDSALq9iNEYg==
update-notifier@^2.3.0, update-notifier@^2.5.0:
version "2.5.0"