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 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 = { declare type LivestreamState = {
fetchingById: {}, fetchingById: {},
viewersById: {}, viewersById: {},
fetchingActiveLivestreams: boolean, fetchingActiveLivestreams: boolean | string,
activeLivestreams: ?LivestreamInfo, activeLivestreams: ?LivestreamInfo,
activeLivestreamsLastFetchedDate: number, activeLivestreamsLastFetchedDate: number,
activeLivestreamsLastFetchedOptions: {}, activeLivestreamsLastFetchedOptions: {},
currentChannelStatus: LivestreamChannelStatus,
} }
declare type LivestreamInfo = { declare type LivestreamInfo = {
@ -35,7 +48,7 @@ declare type LivestreamInfo = {
live: boolean, live: boolean,
viewCount: number, viewCount: number,
creatorId: string, creatorId: string,
latestClaimId: string, claimId: string,
latestClaimUri: string, claimUri: string,
} }
} }

View file

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

View file

@ -1795,7 +1795,6 @@
"Create A LiveStream": "Create A LiveStream", "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!", "%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!", "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.", "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", "Right now": "Right now",
"%viewer_count% currently watching": "%viewer_count% currently watching", "%viewer_count% currently watching": "%viewer_count% currently watching",
@ -1805,7 +1804,7 @@
"Livestream": "Livestream", "Livestream": "Livestream",
"Your stream key": "Your stream key", "Your stream key": "Your stream key",
"Stream server": "Stream server", "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 livestream uploads": "Your livestream uploads",
"Your pending livestream uploads": "Your pending livestream uploads", "Your pending livestream uploads": "Your pending livestream uploads",
"No livestream publishes found": "No livestream publishes found", "No livestream publishes found": "No livestream publishes found",
@ -2207,5 +2206,10 @@
"Cookies": "Cookies", "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!", "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.", "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--" "--end--": "--end--"
} }

View file

@ -13,6 +13,8 @@ import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings'; import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings';
import { doFetchActiveLivestream } from 'redux/actions/livestream';
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
import ChannelContent from './view'; import ChannelContent from './view';
@ -32,11 +34,13 @@ const select = (state, props) => {
isAuthenticated: selectUserVerifiedEmail(state), isAuthenticated: selectUserVerifiedEmail(state),
showMature: selectShowMatureContent(state), showMature: selectShowMatureContent(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT), tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
currentChannelStatus: selectCurrentChannelStatus(state),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doResolveUris: (uris, returnCachedUris) => dispatch(doResolveUris(uris, returnCachedUris)), doResolveUris: (uris, returnCachedUris) => dispatch(doResolveUris(uris, returnCachedUris)),
doFetchActiveLivestream: (channelID) => dispatch(doFetchActiveLivestream(channelID)),
}); });
export default withRouter(connect(select, perform)(ChannelContent)); 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 { Form, FormField } from 'component/common/form';
import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search'; import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search';
import { lighthouse } from 'redux/actions/search'; import { lighthouse } from 'redux/actions/search';
import ScheduledStreams from 'component/scheduledStreams';
const TYPES_TO_ALLOW_FILTER = ['stream', 'repost']; const TYPES_TO_ALLOW_FILTER = ['stream', 'repost'];
@ -36,6 +37,8 @@ type Props = {
doResolveUris: (Array<string>, boolean) => void, doResolveUris: (Array<string>, boolean) => void,
claimType: string, claimType: string,
empty?: string, empty?: string,
doFetchActiveLivestream: (string) => void,
currentChannelStatus: LivestreamChannelStatus,
}; };
function ChannelContent(props: Props) { function ChannelContent(props: Props) {
@ -55,6 +58,8 @@ function ChannelContent(props: Props) {
doResolveUris, doResolveUris,
claimType, claimType,
empty, empty,
doFetchActiveLivestream,
currentChannelStatus,
} = props; } = props;
// const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; // const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
const claimsInChannel = 9999; const claimsInChannel = 9999;
@ -65,6 +70,7 @@ function ChannelContent(props: Props) {
} = useHistory(); } = useHistory();
const url = `${pathname}${search}`; const url = `${pathname}${search}`;
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const isChannelEmpty = !claim || !claim.meta;
const showFilters = const showFilters =
!claimType || !claimType ||
(Array.isArray(claimType) (Array.isArray(claimType)
@ -239,20 +245,52 @@ function ChannelContent(props: Props) {
} }
}, DEBOUNCE_WAIT_DURATION_MS); }, DEBOUNCE_WAIT_DURATION_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [claimId, searchQuery, showMature]); }, [claimId, searchQuery, showMature, doResolveUris]);
React.useEffect(() => { React.useEffect(() => {
setSearchQuery(''); setSearchQuery('');
setSearchResults(null); setSearchResults(null);
}, [url]); }, [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 ( return (
<Fragment> <Fragment>
{!fetching && Boolean(claimsInChannel) && !channelIsBlocked && !channelIsBlackListed && ( {!fetching && Boolean(claimsInChannel) && !channelIsBlocked && !channelIsBlackListed && (
<HiddenNsfwClaims uri={uri} /> <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 && ( {!fetching && channelIsBlackListed && (
<section className="card card--section"> <section className="card card--section">
@ -277,42 +315,44 @@ function ChannelContent(props: Props) {
<Ads type="homepage" /> <Ads type="homepage" />
<ClaimListDiscover {!fetching && (
hasSource <ClaimListDiscover
defaultFreshness={CS.FRESH_ALL} hasSource
showHiddenByUser={viewHiddenChannels} defaultFreshness={CS.FRESH_ALL}
forceShowReposts showHiddenByUser={viewHiddenChannels}
fetchViewCount forceShowReposts
hideFilters={!showFilters} fetchViewCount
hideAdvancedFilter={!showFilters} hideFilters={!showFilters}
tileLayout={tileLayout} hideAdvancedFilter={!showFilters}
uris={searchResults} tileLayout={tileLayout}
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined} uris={searchResults}
channelIds={[claimId]} streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined}
claimType={claimType} channelIds={[claimId]}
feeAmount={CS.FEE_AMOUNT_ANY} claimType={claimType}
defaultOrderBy={CS.ORDER_BY_NEW} feeAmount={CS.FEE_AMOUNT_ANY}
pageSize={defaultPageSize} defaultOrderBy={CS.ORDER_BY_NEW}
infiniteScroll={defaultInfiniteScroll} pageSize={defaultPageSize}
injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />} infiniteScroll={defaultInfiniteScroll}
meta={ injectedItem={SHOW_ADS && !isAuthenticated && IS_WEB && <Ads type="video" />}
showFilters && ( meta={
<Form onSubmit={() => {}} className="wunderbar--inline"> showFilters && (
<Icon icon={ICONS.SEARCH} /> <Form onSubmit={() => {}} className="wunderbar--inline">
<FormField <Icon icon={ICONS.SEARCH} />
className="wunderbar__input--inline" <FormField
value={searchQuery} className="wunderbar__input--inline"
onChange={handleInputChange} value={searchQuery}
type="text" onChange={handleInputChange}
placeholder={__('Search')} type="text"
/> placeholder={__('Search')}
</Form> />
) </Form>
} )
isChannel }
channelIsMine={channelIsMine} isChannel
empty={empty} channelIsMine={channelIsMine}
/> empty={empty}
/>
)}
</Fragment> </Fragment>
); );
} }

View file

@ -44,6 +44,9 @@ type Props = {
collectionId?: string, collectionId?: string,
showNoSourceClaims?: boolean, showNoSourceClaims?: boolean,
onClick?: (e: any, claim?: ?Claim, index?: number) => void, onClick?: (e: any, claim?: ?Claim, index?: number) => void,
maxClaimRender?: number,
excludeUris?: Array<string>,
loadedCallback?: (number) => void,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -74,6 +77,9 @@ export default function ClaimList(props: Props) {
collectionId, collectionId,
showNoSourceClaims, showNoSourceClaims,
onClick, onClick,
maxClaimRender,
excludeUris = [],
loadedCallback,
} = props; } = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
@ -83,8 +89,15 @@ export default function ClaimList(props: Props) {
const timedOut = uris === null; const timedOut = uris === null;
const urisLength = (uris && uris.length) || 0; const urisLength = (uris && uris.length) || 0;
const tileUris = (prefixUris || []).concat(uris); let tileUris = (prefixUris || []).concat(uris || []);
const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || []; 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 const noResultMsg = searchInLanguage
? __('No results. Contents may be hidden by the Language filter.') ? __('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 --- // --- perform ---
doClaimSearch: ({}) => void, doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void, doFetchViewCount: (claimIdCsv: string) => void,
hideLayoutButton?: boolean,
loadedCallback?: (number) => void,
maxClaimRender?: number,
useSkeletonScreen?: boolean,
excludeUris?: Array<string>,
}; };
function ClaimListDiscover(props: Props) { function ClaimListDiscover(props: Props) {
@ -158,6 +164,11 @@ function ClaimListDiscover(props: Props) {
empty, empty,
claimsByUri, claimsByUri,
doFetchViewCount, doFetchViewCount,
hideLayoutButton = false,
loadedCallback,
maxClaimRender,
useSkeletonScreen = true,
excludeUris = [],
} = props; } = props;
const didNavigateForward = history.action === 'PUSH'; const didNavigateForward = history.action === 'PUSH';
const { search } = location; const { search } = location;
@ -515,12 +526,21 @@ function ClaimListDiscover(props: Props) {
} }
function resolveOrderByOption(orderBy: string | Array<string>, sortBy: string | Array<string>) { function resolveOrderByOption(orderBy: string | Array<string>, sortBy: string | Array<string>) {
const order_by = let order_by;
orderBy === CS.ORDER_BY_TRENDING
? CS.ORDER_BY_TRENDING_VALUE switch (orderBy) {
: orderBy === CS.ORDER_BY_NEW case CS.ORDER_BY_TRENDING:
? CS.ORDER_BY_NEW_VALUE order_by = CS.ORDER_BY_TRENDING_VALUE;
: CS.ORDER_BY_TOP_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) { if (orderBy === CS.ORDER_BY_NEW && sortBy === CS.SORT_BY.OLDEST.key) {
return order_by.map((x) => `${CS.SORT_BY.OLDEST.opt}${x}`); return order_by.map((x) => `${CS.SORT_BY.OLDEST.opt}${x}`);
@ -578,6 +598,7 @@ function ClaimListDiscover(props: Props) {
hiddenNsfwMessage={hiddenNsfwMessage} hiddenNsfwMessage={hiddenNsfwMessage}
setPage={setPage} setPage={setPage}
tileLayout={tileLayout} tileLayout={tileLayout}
hideLayoutButton={hideLayoutButton}
hideFilters={hideFilters} hideFilters={hideFilters}
scrollAnchor={scrollAnchor} scrollAnchor={scrollAnchor}
/> />
@ -610,8 +631,11 @@ function ClaimListDiscover(props: Props) {
searchOptions={options} searchOptions={options}
showNoSourceClaims={showNoSourceClaims} showNoSourceClaims={showNoSourceClaims}
empty={empty} empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
loadedCallback={loadedCallback}
/> />
{loading && ( {loading && useSkeletonScreen && (
<div className="claim-grid"> <div className="claim-grid">
{new Array(dynamicPageSize).fill(1).map((x, i) => ( {new Array(dynamicPageSize).fill(1).map((x, i) => (
<ClaimPreviewTile key={i} placeholder="loading" /> <ClaimPreviewTile key={i} placeholder="loading" />
@ -643,8 +667,12 @@ function ClaimListDiscover(props: Props) {
searchOptions={options} searchOptions={options}
showNoSourceClaims={hasNoSource || showNoSourceClaims} showNoSourceClaims={hasNoSource || showNoSourceClaims}
empty={empty} empty={empty}
maxClaimRender={maxClaimRender}
excludeUris={excludeUris}
loadedCallback={loadedCallback}
/> />
{loading && {loading &&
useSkeletonScreen &&
new Array(dynamicPageSize) new Array(dynamicPageSize)
.fill(1) .fill(1)
.map((x, i) => ( .map((x, i) => (

View file

@ -24,6 +24,7 @@ type Props = {
orderBy?: Array<string>, orderBy?: Array<string>,
defaultOrderBy?: string, defaultOrderBy?: string,
hideAdvancedFilter: boolean, hideAdvancedFilter: boolean,
hideLayoutButton: boolean,
hasMatureTags: boolean, hasMatureTags: boolean,
hiddenNsfwMessage?: Node, hiddenNsfwMessage?: Node,
channelIds?: Array<string>, channelIds?: Array<string>,
@ -49,6 +50,7 @@ function ClaimListHeader(props: Props) {
orderBy, orderBy,
defaultOrderBy, defaultOrderBy,
hideAdvancedFilter, hideAdvancedFilter,
hideLayoutButton,
hasMatureTags, hasMatureTags,
hiddenNsfwMessage, hiddenNsfwMessage,
channelIds, channelIds,
@ -269,7 +271,7 @@ function ClaimListHeader(props: Props) {
/> />
)} )}
{tileLayout !== undefined && ( {tileLayout !== undefined && !hideLayoutButton && (
<Button <Button
onClick={() => { onClick={() => {
doSetClientSetting(SETTINGS.TILE_LAYOUT, !tileLayout); 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 { push } from 'connected-react-router';
import ClaimPreviewSubtitle from './view'; import ClaimPreviewSubtitle from './view';
import { doFetchSubCount, selectSubCountForUri } from 'lbryinc'; import { doFetchSubCount, selectSubCountForUri } from 'lbryinc';
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
const select = (state, props) => { const select = (state, props) => {
const claim = selectClaimForUri(state, props.uri); const claim = selectClaimForUri(state, props.uri);
const isChannel = claim && claim.value_type === 'channel'; const isChannel = claim && claim.value_type === 'channel';
const isLivestream = isStreamPlaceholderClaim(claim);
return { return {
claim, claim,
pending: makeSelectClaimIsPending(props.uri)(state), pending: makeSelectClaimIsPending(props.uri)(state),
isLivestream: isStreamPlaceholderClaim(claim), isLivestream,
subCount: isChannel ? selectSubCountForUri(state, props.uri) : 0, subCount: isChannel ? selectSubCountForUri(state, props.uri) : 0,
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
}; };
}; };

View file

@ -1,26 +1,29 @@
// @flow // @flow
import { ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import React from 'react'; import React, { useContext } from 'react';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import DateTime from 'component/dateTime'; import DateTime from 'component/dateTime';
import Button from 'component/button'; import Button from 'component/button';
import FileViewCountInline from 'component/fileViewCountInline'; import FileViewCountInline from 'component/fileViewCountInline';
import { parseURI } from 'util/lbryURI'; import { parseURI } from 'util/lbryURI';
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
import moment from 'moment';
type Props = { type Props = {
uri: string, uri: string,
claim: ?Claim, claim: ?StreamClaim,
pending?: boolean, pending?: boolean,
type: string, type: string,
beginPublish: (?string) => void, beginPublish: (?string) => void,
isLivestream: boolean, isLivestream: boolean,
fetchSubCount: (string) => void, fetchSubCount: (string) => void,
subCount: number, subCount: number,
isLivestreamActive: boolean,
}; };
// previews used in channel overview and homepage (and other places?) // previews used in channel overview and homepage (and other places?)
function ClaimPreviewSubtitle(props: Props) { 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 isChannel = claim && claim.value_type === 'channel';
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
@ -38,6 +41,29 @@ function ClaimPreviewSubtitle(props: Props) {
({ streamName: name } = parseURI(uri)); ({ streamName: name } = parseURI(uri));
} catch (e) {} } 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 ( return (
<div className="media__subtitle"> <div className="media__subtitle">
{claim ? ( {claim ? (
@ -56,7 +82,7 @@ function ClaimPreviewSubtitle(props: Props) {
{!isChannel && {!isChannel &&
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? ( (isLivestream && ENABLE_NO_SOURCE_CLAIMS ? (
__('Livestream') <LivestreamDateTimeLabel />
) : ( ) : (
<> <>
<FileViewCountInline uri={uri} isLivestream={isLivestream} /> <FileViewCountInline uri={uri} isLivestream={isLivestream} />

View file

@ -1,5 +1,5 @@
// @flow // @flow
import React from 'react'; import React, { useContext } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { NavLink, withRouter } from 'react-router-dom'; import { NavLink, withRouter } from 'react-router-dom';
import FileThumbnail from 'component/fileThumbnail'; import FileThumbnail from 'component/fileThumbnail';
@ -19,6 +19,8 @@ import FileWatchLaterLink from 'component/fileWatchLaterLink';
import ClaimRepostAuthor from 'component/claimRepostAuthor'; import ClaimRepostAuthor from 'component/claimRepostAuthor';
import ClaimMenuList from 'component/claimMenuList'; import ClaimMenuList from 'component/claimMenuList';
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay'; import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
import moment from 'moment';
// $FlowFixMe cannot resolve ... // $FlowFixMe cannot resolve ...
import PlaceholderTx from 'static/img/placeholderTx.gif'; 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 signingChannel = claim && claim.signing_channel;
const isChannel = claim && claim.value_type === 'channel'; const isChannel = claim && claim.value_type === 'channel';
const channelUri = !isChannel ? signingChannel && signingChannel.permanent_url : claim && claim.permanent_url; const channelUri = !isChannel ? signingChannel && signingChannel.permanent_url : claim && claim.permanent_url;
@ -167,10 +171,27 @@ function ClaimPreviewTile(props: Props) {
} }
let liveProperty = null; let liveProperty = null;
if (isLivestreamActive === true) { if (isLivestream === true) {
liveProperty = (claim) => <>LIVE</>; 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 ( return (
<li <li
onClick={handleClick} onClick={handleClick}
@ -239,7 +260,8 @@ function ClaimPreviewTile(props: Props) {
<UriIndicator uri={uri} link /> <UriIndicator uri={uri} link />
<div className="claim-tile__about--counts"> <div className="claim-tile__about--counts">
<FileViewCountInline uri={uri} isLivestream={isLivestream} /> <FileViewCountInline uri={uri} isLivestream={isLivestream} />
<DateTime timeAgo uri={uri} /> {isLivestream && <LivestreamDateTimeLabel />}
{!isLivestream && <DateTime timeAgo uri={uri} />}
</div> </div>
</div> </div>
</React.Fragment> </React.Fragment>

View file

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

View file

@ -18,7 +18,7 @@ function FileSubtitle(props: Props) {
<> <>
<div className="media__subtitle--between"> <div className="media__subtitle--between">
<div className="file__viewdate"> <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} /> <FileViewCount uri={uri} livestream={livestream} isLive={isLive} />
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,72 +1,30 @@
// @flow // @flow
import * as CS from 'constants/claim_search';
import React from 'react'; import React from 'react';
import Card from 'component/common/card'; import Card from 'component/common/card';
import ClaimPreview from 'component/claimPreview'; import ClaimPreview from 'component/claimPreview';
import Lbry from 'lbry';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url'; import { formatLbryUrlForWeb } from 'util/url';
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
type Props = { type Props = {
channelClaim: ChannelClaim, claimUri: string,
}; };
export default function LivestreamLink(props: Props) { export default function LivestreamLink(props: Props) {
const { channelClaim } = props; const { claimUri } = props;
const { push } = useHistory(); 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 }) => ( const element = (props: { children: any }) => (
<Card <Card
className="livestream__channel-link claim-preview__live" className="livestream__channel-link claim-preview__live"
title={__('Live stream in progress')} title={__('Live stream in progress')}
onClick={() => { onClick={() => {
push(formatLbryUrlForWeb(livestreamClaim.canonical_url)); push(formatLbryUrlForWeb(claimUri));
}} }}
> >
{props.children} {props.children}
</Card> </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, needsYTAuth: boolean,
fetchAccessToken: () => void, fetchAccessToken: () => void,
accessToken: string, accessToken: string,
isLivestreamMode: boolean,
}; };
function PublishAdditionalOptions(props: Props) { function PublishAdditionalOptions(props: Props) {
@ -39,6 +40,7 @@ function PublishAdditionalOptions(props: Props) {
otherLicenseDescription, otherLicenseDescription,
licenseUrl, licenseUrl,
updatePublishForm, updatePublishForm,
isLivestreamMode,
// user, // user,
// useLBRYUploader, // useLBRYUploader,
// needsYTAuth, // needsYTAuth,
@ -154,7 +156,7 @@ function PublishAdditionalOptions(props: Props) {
)} */} )} */}
{/* @endif */} {/* @endif */}
<div className="section"> <div className="section">
<PublishReleaseDate /> {!isLivestreamMode && <PublishReleaseDate />}
<FormField <FormField
label={__('Language')} label={__('Language')}

View file

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

View file

@ -19,18 +19,28 @@ type Props = {
releaseTime: ?number, releaseTime: ?number,
releaseTimeEdited: ?number, releaseTimeEdited: ?number,
updatePublishForm: ({}) => void, updatePublishForm: ({}) => void,
allowDefault: ?boolean,
showNowBtn: ?boolean,
useMaxDate: ?boolean,
}; };
const PublishReleaseDate = (props: Props) => { const PublishReleaseDate = (props: Props) => {
const { releaseTime, releaseTimeEdited, updatePublishForm } = props; const {
const maxDate = new Date(); 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 [date, setDate] = React.useState(releaseTime ? linuxTimestampToDate(releaseTime) : new Date());
const isNew = releaseTime === undefined; const isNew = releaseTime === undefined;
const isEdit = !isNew; const isEdit = !isNew || allowDefault === false;
const showEditBtn = isNew && releaseTimeEdited === undefined; const showEditBtn = isNew && releaseTimeEdited === undefined && allowDefault !== false;
const showDefaultBtn = isNew && releaseTimeEdited !== undefined; const showDefaultBtn = isNew && releaseTimeEdited !== undefined && allowDefault !== false;
const showDatePicker = isEdit || releaseTimeEdited !== undefined; const showDatePicker = isEdit || releaseTimeEdited !== undefined;
const onDateTimePickerChanged = (value) => { const onDateTimePickerChanged = (value) => {
@ -108,7 +118,7 @@ const PublishReleaseDate = (props: Props) => {
onClick={() => newDate(RESET_TO_ORIGINAL)} onClick={() => newDate(RESET_TO_ORIGINAL)}
/> />
)} )}
{showDatePicker && ( {showDatePicker && showNowBtn && (
<Button <Button
button="link" button="link"
label={__('Now')} 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_SKIPPED = 'FETCH_ACTIVE_LIVESTREAMS_SKIPPED';
export const FETCH_ACTIVE_LIVESTREAMS_COMPLETED = 'FETCH_ACTIVE_LIVESTREAMS_COMPLETED'; 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 // Blacklist
export const FETCH_BLACK_LISTED_CONTENT_STARTED = 'FETCH_BLACK_LISTED_CONTENT_STARTED'; export const FETCH_BLACK_LISTED_CONTENT_STARTED = 'FETCH_BLACK_LISTED_CONTENT_STARTED';
export const FETCH_BLACK_LISTED_CONTENT_COMPLETED = 'FETCH_BLACK_LISTED_CONTENT_COMPLETED'; 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 = 'trending';
export const ORDER_BY_TRENDING_VALUE = ['trending_group', 'trending_mixed']; export const ORDER_BY_TRENDING_VALUE = ['trending_group', 'trending_mixed'];
export const ORDER_BY_TOP = 'top'; export const ORDER_BY_TOP = 'top';
export const ORDER_BY_TOP_VALUE = ['effective_amount']; export const ORDER_BY_TOP_VALUE = ['effective_amount'];
export const ORDER_BY_NEW = 'new'; export const ORDER_BY_NEW = 'new';
export const ORDER_BY_NEW_VALUE = ['release_time']; 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 ORDER_BY_TYPES = [ORDER_BY_TRENDING, ORDER_BY_NEW, ORDER_BY_TOP];
export const DURATION_SHORT = 'short'; 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 LIVESTREAM_KILL = 'https://api.stream.odysee.com/stream/kill';
export const MAX_LIVESTREAM_COMMENTS = 50; 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; const txFee = previewResponse ? previewResponse['total_fee'] : null;
// $FlowFixMe add outputs[0] etc to PublishResponse type // $FlowFixMe add outputs[0] etc to PublishResponse type
const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available; const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available;
@ -153,7 +155,7 @@ const ModalPublishPreview = (props: Props) => {
modalTitle = __('Confirm Edit'); modalTitle = __('Confirm Edit');
} }
} else if (livestream) { } else if (livestream) {
modalTitle = __('Create Livestream'); modalTitle = releasesInFuture ? __('Schedule Livestream') : __('Create Livestream');
} else { } else {
modalTitle = __('Confirm Upload'); modalTitle = __('Confirm Upload');
} }
@ -177,6 +179,8 @@ const ModalPublishPreview = (props: Props) => {
} }
} }
const releaseDateText = releasesInFuture ? __('Scheduled for') : __('Release date');
const descriptionValue = description ? ( const descriptionValue = description ? (
<div className="media__info-text-preview"> <div className="media__info-text-preview">
<MarkdownPreview content={description} simpleLinks /> <MarkdownPreview content={description} simpleLinks />
@ -250,7 +254,7 @@ const ModalPublishPreview = (props: Props) => {
{createRow(__('Deposit'), depositValue)} {createRow(__('Deposit'), depositValue)}
{createRow(__('Price'), priceValue)} {createRow(__('Price'), priceValue)}
{createRow(__('Language'), language)} {createRow(__('Language'), language)}
{releaseTimeEdited && createRow(__('Release date'), releaseTimeStr(releaseTimeEdited))} {releaseTimeEdited && createRow(releaseDateText, releaseTimeStr(releaseTimeEdited))}
{createRow(__('License'), licenseValue)} {createRow(__('License'), licenseValue)}
{createRow(__('Tags'), tagsValue)} {createRow(__('Tags'), tagsValue)}
</tbody> </tbody>

View file

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

View file

@ -11,52 +11,71 @@ import Button from 'component/button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { splitBySeparator } from 'util/lbryURI'; import { splitBySeparator } from 'util/lbryURI';
import { getLivestreamUris } from 'util/livestream'; import { getLivestreamUris } from 'util/livestream';
import ScheduledStreams from 'component/scheduledStreams';
type Props = { type Props = {
subscribedChannels: Array<Subscription>, subscribedChannels: Array<Subscription>,
tileLayout: boolean, tileLayout: boolean,
activeLivestreams: ?LivestreamInfo, activeLivestreams: ?LivestreamInfo,
doFetchActiveLivestreams: () => void, doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean,
}; };
function ChannelsFollowingPage(props: Props) { 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]); const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
React.useEffect(() => { React.useEffect(() => {
doFetchActiveLivestreams(); doFetchActiveLivestreams();
}, []); }, []);
return !hasSubsribedChannels ? ( return !hasSubscribedChannels ? (
<ChannelsFollowingDiscoverPage /> <ChannelsFollowingDiscoverPage />
) : ( ) : (
<Page noFooter fullWidthPage={tileLayout}> <Page noFooter fullWidthPage={tileLayout}>
<ClaimListDiscover {!fetchingActiveLivestreams && (
prefixUris={getLivestreamUris(activeLivestreams, channelIds)} <>
hideAdvancedFilter={SIMPLE_SITE} <ScheduledStreams
streamType={SIMPLE_SITE ? CS.CONTENT_ALL : undefined} channelIds={channelIds}
tileLayout={tileLayout} tileLayout={tileLayout}
headerLabel={ liveUris={getLivestreamUris(activeLivestreams, channelIds)}
<span> limitClaimsPerChannel={2}
<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} <ClaimListDiscover
hasSource 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> </Page>
); );
} }

View file

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

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; 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 { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectSubscriptions } from 'redux/selectors/subscriptions';
@ -15,6 +15,7 @@ const select = (state) => ({
showNsfw: selectShowMatureContent(state), showNsfw: selectShowMatureContent(state),
homepageData: selectHomepageData(state), homepageData: selectHomepageData(state),
activeLivestreams: selectActiveLivestreams(state), activeLivestreams: selectActiveLivestreams(state),
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
}); });
const perform = (dispatch) => ({ 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 { useIsLargeScreen } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage'; import { GetLinksData } from 'util/buildHomepage';
import { getLivestreamUris } from 'util/livestream'; import { getLivestreamUris } from 'util/livestream';
import ScheduledStreams from 'component/scheduledStreams';
import { splitBySeparator } from 'util/lbryURI';
// @if TARGET='web' // @if TARGET='web'
import Pixel from 'web/component/pixel'; import Pixel from 'web/component/pixel';
@ -27,6 +29,7 @@ type Props = {
homepageData: any, homepageData: any,
activeLivestreams: any, activeLivestreams: any,
doFetchActiveLivestreams: () => void, doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean,
}; };
function HomePage(props: Props) { function HomePage(props: Props) {
@ -38,12 +41,15 @@ function HomePage(props: Props) {
homepageData, homepageData,
activeLivestreams, activeLivestreams,
doFetchActiveLivestreams, doFetchActiveLivestreams,
fetchingActiveLivestreams,
} = props; } = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0; const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0; const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
const showIndividualTags = showPersonalizedTags && followedTags.length < 5; const showIndividualTags = showPersonalizedTags && followedTags.length < 5;
const isLargeScreen = useIsLargeScreen(); const isLargeScreen = useIsLargeScreen();
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
const rowData: Array<RowDataItem> = GetLinksData( const rowData: Array<RowDataItem> = GetLinksData(
homepageData, homepageData,
isLargeScreen, isLargeScreen,
@ -254,14 +260,28 @@ function HomePage(props: Props) {
</p> </p>
</div> </div>
)} )}
{/* @if TARGET='web' */} {/* @if TARGET='web' */}
{SIMPLE_SITE && <Meme />} {SIMPLE_SITE && <Meme />}
<Ads type="homepage" /> <Ads type="homepage" />
{/* @endif */} {/* @endif */}
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
// add pins here {!fetchingActiveLivestreams && (
return getRowElements(title, route, link, icon, help, options, index, pinUrls); <>
})} {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' */} {/* @if TARGET='web' */}
<Pixel type={'retargeting'} /> <Pixel type={'retargeting'} />
{/* @endif */} {/* @endif */}

View file

@ -4,16 +4,25 @@ import { doSetPlayingUri } from 'redux/actions/content';
import { doUserSetReferrer } from 'redux/actions/user'; import { doUserSetReferrer } from 'redux/actions/user';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { DISABLE_COMMENTS_TAG } from 'constants/tags'; import { DISABLE_COMMENTS_TAG } from 'constants/tags';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
import { doFetchActiveLivestream } from 'redux/actions/livestream';
import LivestreamPage from './view'; import LivestreamPage from './view';
const select = (state, props) => ({ const select = (state, props) => ({
isAuthenticated: selectUserVerifiedEmail(state), isAuthenticated: selectUserVerifiedEmail(state),
channelClaimId: getChannelIdFromClaim(selectClaimForUri(state, props.uri)), channelClaimId: getChannelIdFromClaim(selectClaimForUri(state, props.uri)),
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state), chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
currentChannelStatus: selectCurrentChannelStatus(state),
}); });
export default connect(select, { const perform = {
doSetPlayingUri, doSetPlayingUri,
doUserSetReferrer, 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 Page from 'component/page';
import LivestreamLayout from 'component/livestreamLayout'; import LivestreamLayout from 'component/livestreamLayout';
import analytics from 'analytics'; 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" */)); const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
@ -16,46 +17,135 @@ type Props = {
doUserSetReferrer: (string) => void, doUserSetReferrer: (string) => void,
channelClaimId: ?string, channelClaimId: ?string,
chatDisabled: boolean, chatDisabled: boolean,
doCommentSocketConnect: (string, string) => void,
doCommentSocketDisconnect: (string) => void,
doFetchActiveLivestream: (string) => void,
currentChannelStatus: LivestreamChannelStatus,
}; };
export default function LivestreamPage(props: Props) { export default function LivestreamPage(props: Props) {
const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer, channelClaimId, chatDisabled } = props; const {
const [isLive, setIsLive] = React.useState('pending'); uri,
const livestreamChannelId = channelClaimId; claim,
doSetPlayingUri,
isAuthenticated,
doUserSetReferrer,
channelClaimId,
chatDisabled,
doCommentSocketConnect,
doCommentSocketDisconnect,
doFetchActiveLivestream,
currentChannelStatus,
} = props;
React.useEffect(() => { React.useEffect(() => {
// TODO: This should not be needed one we unify the livestream player (?) // TODO: This should not be needed one we unify the livestream player (?)
analytics.playerLoadedEvent('livestream', false); analytics.playerLoadedEvent('livestream', false);
}, []); }, []);
const claimId = claim && claim.claim_id;
// Establish web socket connection for viewer count.
React.useEffect(() => { React.useEffect(() => {
if (!livestreamChannelId) { if (claimId) {
setIsLive(false); doCommentSocketConnect(uri, claimId);
return;
} }
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); const stringifiedClaim = JSON.stringify(claim);
React.useEffect(() => { React.useEffect(() => {
if (uri && stringifiedClaim) { if (uri && stringifiedClaim) {
const jsonClaim = JSON.parse(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) { if (!isAuthenticated) {
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url; const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
if (uri) { 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(() => { React.useEffect(() => {
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back // 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]); }, [doSetPlayingUri]);
return ( return (
isLive !== 'pending' && ( <Page
<Page className="file-page"
className="file-page" noFooter
noFooter livestream
livestream chatDisabled={hideComments}
chatDisabled={chatDisabled} rightSide={
rightSide={ !hideComments &&
!chatDisabled && ( isInitialized && (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<LivestreamComments uri={uri} /> <LivestreamComments uri={uri} />
</React.Suspense> </React.Suspense>
) )
} }
> >
<LivestreamLayout uri={uri} isLive={isLive} /> {isInitialized && (
</Page> <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>, pendingClaims: Array<Claim>,
doNewLivestream: (string) => void, doNewLivestream: (string) => void,
fetchNoSourceClaims: (string) => void, fetchNoSourceClaims: (string) => void,
myLivestreamClaims: Array<Claim>, myLivestreamClaims: Array<StreamClaim>,
fetchingLivestreams: boolean, fetchingLivestreams: boolean,
channelId: ?string, channelId: ?string,
channelName: ?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.` `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.` `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> <p>
{__(`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.`)}
</p> </p>
<p>{__(`For streaming from your mobile device, we recommend PRISM Live Studio from the app store.`)}</p>
<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).` `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]); }, [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 ( return (
<Page> <Page>
{fetchingChannels && ( {fetchingChannels && (
@ -150,10 +187,11 @@ export default function LivestreamSetupPage(props: Props) {
/> />
)} )}
{!fetchingChannels && ( {!fetchingChannels && (
<div className="section__actions--between"> <>
<ChannelSelector hideAnon /> <div className="section__actions--between">
<Button button="link" onClick={() => setShowHelp(!showHelp)} label={__('How does this work?')} /> <ChannelSelector hideAnon />
</div> </div>
</>
)} )}
{fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && ( {fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
@ -164,14 +202,14 @@ export default function LivestreamSetupPage(props: Props) {
<div className="card-stack"> <div className="card-stack">
{!fetchingChannels && channelId && ( {!fetchingChannels && channelId && (
<> <>
{showHelp && ( <Card
<Card titleActions={
titleActions={<Button button="close" icon={ICONS.REMOVE} onClick={() => setShowHelp(false)} />} <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!`)} title={__('Go Live on Odysee')}
actions={helpText} subtitle={<>{__(`You're invited to try out our new livestreaming service while in beta!`)} </>}
/> actions={showHelp && helpText}
)} />
{streamKey && totalLivestreamClaims.length > 0 && ( {streamKey && totalLivestreamClaims.length > 0 && (
<Card <Card
className="section" className="section"
@ -189,7 +227,7 @@ export default function LivestreamSetupPage(props: Props) {
primaryButton primaryButton
enableInputMask enableInputMask
name="livestream-key" name="livestream-key"
label={__('Stream key')} label={__('Stream key (can be reused)')}
copyable={streamKey} copyable={streamKey}
snackMessage={__('Copied stream key.')} snackMessage={__('Copied stream key.')}
/> />
@ -203,37 +241,45 @@ export default function LivestreamSetupPage(props: Props) {
{Boolean(pendingClaims.length) && ( {Boolean(pendingClaims.length) && (
<div className="section"> <div className="section">
<ClaimList <ClaimList
header={__('Your pending livestream uploads')} header={__('Your pending livestreams uploads')}
uris={pendingClaims.map((claim) => claim.permanent_url)} uris={pendingClaims.map((claim) => claim.permanent_url)}
/> />
</div> </div>
)} )}
{Boolean(myLivestreamClaims.length) && ( {Boolean(myLivestreamClaims.length) && (
<div className="section"> <>
<ClaimList {Boolean(upcomingStreams.length) && (
header={__('Your livestream uploads')} <div className="section">
empty={ <ClaimList
<I18nMessage header={<ListHeader title={__('Your Scheduled Livestreams')} />}
tokens={{ uris={upcomingStreams.map((claim) => claim.permanent_url)}
check_again: ( />
<Button </div>
button="link" )}
onClick={() => fetchNoSourceClaims(channelId)} <div className="section">
label={__('Check again')} <ClaimList
/> header={
), <ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} />
}} }
> empty={
Nothing here yet. %check_again% <I18nMessage
</I18nMessage> tokens={{
} check_again: (
uris={myLivestreamClaims <Button
.filter( button="link"
(claim) => !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url) onClick={() => fetchNoSourceClaims(channelId)}
) label={__('Check again')}
.map((claim) => claim.permanent_url)} />
/> ),
</div> }}
>
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 { push } from 'connected-react-router';
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions'; import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { selectBlacklistedOutpointMap } from 'lbryinc'; import { selectBlacklistedOutpointMap } from 'lbryinc';
import { doAnalyticsView } from 'redux/actions/app';
import ShowPage from './view'; import ShowPage from './view';
const select = (state, props) => { const select = (state, props) => {
@ -96,6 +97,7 @@ const perform = (dispatch) => ({
dispatch(push(`/$/${PAGES.UPLOAD}`)); dispatch(push(`/$/${PAGES.UPLOAD}`));
}, },
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })), fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })),
doAnalyticsView: (uri) => dispatch(doAnalyticsView(uri)),
}); });
export default withRouter(connect(select, perform)(ShowPage)); export default withRouter(connect(select, perform)(ShowPage));

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { lazyImport } from 'util/lazyImport'; import { lazyImport } from 'util/lazyImport';
import { Redirect, useHistory } from 'react-router-dom'; import { Redirect, useHistory } from 'react-router-dom';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
@ -39,6 +39,7 @@ type Props = {
collectionUrls: Array<string>, collectionUrls: Array<string>,
isResolvingCollection: boolean, isResolvingCollection: boolean,
fetchCollectionItems: (string) => void, fetchCollectionItems: (string) => void,
doAnalyticsView: (string) => void,
}; };
function ShowPage(props: Props) { function ShowPage(props: Props) {
@ -59,6 +60,7 @@ function ShowPage(props: Props) {
collection, collection,
collectionUrls, collectionUrls,
isResolvingCollection, isResolvingCollection,
doAnalyticsView,
} = props; } = props;
const { search } = location; const { search } = location;
@ -73,6 +75,8 @@ function ShowPage(props: Props) {
const isCollection = claim && claim.value_type === 'collection'; const isCollection = claim && claim.value_type === 'collection';
const resolvedCollection = collection && collection.id; // not null const resolvedCollection = collection && collection.id; // not null
const showLiveStream = isLivestream && ENABLE_NO_SOURCE_CLAIMS;
// changed this from 'isCollection' to resolve strangers' collections. // changed this from 'isCollection' to resolve strangers' collections.
React.useEffect(() => { React.useEffect(() => {
if (collectionId && !resolvedCollection) { if (collectionId && !resolvedCollection) {
@ -113,6 +117,16 @@ function ShowPage(props: Props) {
} }
}, [resolveUri, isResolvingUri, canonicalUrl, uri, claimExists, haventFetchedYet, isMine, claimIsPending, search]); }, [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 // Don't navigate directly to repost urls
// Always redirect to the actual content // Always redirect to the actual content
// Also need to add repost_url to the Claim type for flow // Also need to add repost_url to the Claim type for flow
@ -201,8 +215,8 @@ function ShowPage(props: Props) {
/> />
</Page> </Page>
); );
} else if (isLivestream && ENABLE_NO_SOURCE_CLAIMS) { } else if (showLiveStream) {
innerContent = <LivestreamPage uri={uri} />; innerContent = <LivestreamPage uri={uri} claim={claim} />;
} else { } else {
innerContent = <FilePage uri={uri} location={location} />; innerContent = <FilePage uri={uri} location={location} />;
} }

View file

@ -1,7 +1,8 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { doClaimSearch } from 'redux/actions/claims'; import { doClaimSearch } from 'redux/actions/claims';
import { LIVESTREAM_LIVE_API } 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) => { export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ dispatch({
@ -35,88 +36,179 @@ export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dis
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000; const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
export const doFetchActiveLivestreams = ( const transformLivestreamData = (data: Array<any>): LivestreamInfo => {
orderBy: Array<string> = ['release_time'], return data.reduce((acc, curr) => {
pageSize: number = 50, acc[curr.claimId] = {
forceFetch: boolean = false 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) => { return async (dispatch: Dispatch, getState: GetState) => {
const state = getState(); const state = getState();
const now = Date.now(); const now = Date.now();
const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate; const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate;
const prevOptions = state.livestream.activeLivestreamsLastFetchedOptions; 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); 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 }); dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
return; return;
} }
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED }); dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
fetch(LIVESTREAM_LIVE_API) try {
.then((res) => res.json()) const liveChannels = await fetchLiveChannels();
.then((res) => { const liveChannelIds = Object.keys(liveChannels);
if (!res.data) {
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
return;
}
const activeLivestreams: LivestreamInfo = res.data.reduce((acc, curr) => { const currentlyLiveClaims = await findActiveStreams(liveChannelIds, nextOptions.order_by, liveChannels, dispatch);
acc[curr.claimId] = { Object.values(currentlyLiveClaims).forEach((claim: any) => {
live: curr.live, const channelId = claim.stream.signing_channel.claim_id;
viewCount: curr.viewCount,
creatorId: curr.claimId,
};
return acc;
}, {});
dispatch( liveChannels[channelId] = {
// ** Creators can have multiple livestream claims (each with unique ...liveChannels[channelId],
// chat), and all of them will play the same stream when creator goes claimId: claim.stream.claim_id,
// live. The UI usually just wants to report the latest claim, so we claimUri: claim.stream.canonical_url,
// 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 });
}); });
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 // @flow
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { handleActions } from 'util/redux-utils'; import { handleActions } from 'util/redux-utils';
const currentChannelStatus: LivestreamChannelStatus = {
channelId: null,
isBroadcasting: false,
liveClaim: {
claimId: null,
claimUri: null,
},
};
const defaultState: LivestreamState = { const defaultState: LivestreamState = {
fetchingById: {}, fetchingById: {},
viewersById: {}, viewersById: {},
fetchingActiveLivestreams: false, fetchingActiveLivestreams: 'pending',
activeLivestreams: null, activeLivestreams: null,
activeLivestreamsLastFetchedDate: 0, activeLivestreamsLastFetchedDate: 0,
activeLivestreamsLastFetchedOptions: {}, activeLivestreamsLastFetchedOptions: {},
currentChannelStatus: {
...currentChannelStatus,
},
}; };
export default handleActions( export default handleActions(
@ -56,6 +70,25 @@ export default handleActions(
activeLivestreamsLastFetchedOptions, 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 defaultState
); );

View file

@ -57,7 +57,11 @@ export const selectIsActiveLivestreamForUri = createCachedSelector(
} }
const activeLivestreamValues = Object.values(activeLivestreams); const activeLivestreamValues = Object.values(activeLivestreams);
// $FlowFixMe - unable to resolve latestClaimUri // $FlowFixMe - unable to resolve claimUri
return activeLivestreamValues.some((v) => v.latestClaimUri === uri); return activeLivestreamValues.some((v) => v.claimUri === uri);
} }
)((state, uri) => String(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 { @keyframes fadeInFromBlack {
0% { 0% {
opacity: 1; opacity: 1;

View file

@ -451,6 +451,11 @@ fieldset-group {
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
} }
.form-field__hint {
font-size: var(--font-xsmall);
color: var(--color-input-label);
}
.form-field__textarea-info { .form-field__textarea-info {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -506,6 +511,7 @@ fieldset-section {
.form-field-date-picker { .form-field-date-picker {
margin-bottom: var(--spacing-l); margin-bottom: var(--spacing-l);
font-size: var(--font-base);
label { label {
display: block; 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-40 {
opacity: 0.4; 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) { if (channelIds && channelIds.length > 0) {
// $FlowFixMe // $FlowFixMe
values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.latestClaimUri)); values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.claimUri));
} else { } else {
// $FlowFixMe // $FlowFixMe
values = values.filter((v) => Boolean(v.latestClaimUri)); values = values.filter((v) => Boolean(v.claimUri));
} }
// $FlowFixMe // $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" version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 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: merge-stream@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -13657,25 +13662,26 @@ react-confetti@^4.0.1:
dependencies: dependencies:
tween-functions "^1.2.0" tween-functions "^1.2.0"
react-date-picker@^8.1.0: react-date-picker@^8.3.3:
version "8.1.1" version "8.3.6"
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.1.1.tgz#1959608cd042c9bfcf2faa6d63a56e9ef6b17e2b" resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.6.tgz#446142bee5691aea66a2bac53313357aca561cd4"
integrity sha512-kFhn+uSJML+EuROvR6qLYU5G3wsxrdB2K1ugh1t6HjJCjphE6ot85jb8THWebqWEcQi07pLseU7ZFpzKDD3A6A== integrity sha512-c1rThf0jSKROoSGLpUEPtcC8VE+XoVgqxh+ng9aLYQvjDMGWQBgoat6Qrj8nRVzvCPpdXV4jqiCB3z2vVVuseA==
dependencies: dependencies:
"@types/react-calendar" "^3.0.0" "@types/react-calendar" "^3.0.0"
"@wojtekmaj/date-utils" "^1.0.3" "@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0" get-user-locale "^1.2.0"
make-event-props "^1.1.0" make-event-props "^1.1.0"
merge-class-names "^1.1.1" merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0" prop-types "^15.6.0"
react-calendar "^3.3.1" react-calendar "^3.3.1"
react-fit "^1.0.3" react-fit "^1.0.3"
update-input-width "^1.1.1" update-input-width "^1.2.2"
react-datetime-picker@^3.2.1: react-datetime-picker@^3.4.3:
version "3.2.1" version "3.4.3"
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.2.1.tgz#d3a9631bcba17bd0047e6424cff0dfe242d9cf0e" resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.4.3.tgz#9163471f72b708185482b6b72cd259da03462f79"
integrity sha512-elybaAL7RJG7r0elYZze5/zQo1ds0v+v89tyZkzEShw+6I1EcveXwYPOMj3aq0k7D5kY/K+dC5dWYw0w4d9kmw== integrity sha512-yuFmh3TJwDo3VnyQF6auRJoeYfFTUtyLsR292lWXieigp0ugKkQefUEzVybZQidiiUlCNK9UQgc37/igl7uBYA==
dependencies: dependencies:
"@wojtekmaj/date-utils" "^1.0.3" "@wojtekmaj/date-utils" "^1.0.3"
get-user-locale "^1.2.0" get-user-locale "^1.2.0"
@ -13684,9 +13690,9 @@ react-datetime-picker@^3.2.1:
prop-types "^15.6.0" prop-types "^15.6.0"
react-calendar "^3.3.1" react-calendar "^3.3.1"
react-clock "^3.0.0" react-clock "^3.0.0"
react-date-picker "^8.1.0" react-date-picker "^8.3.3"
react-fit "^1.0.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: react-dev-utils@^3.0.2, react-dev-utils@^3.1.0:
version "3.1.3" version "3.1.3"
@ -13914,19 +13920,20 @@ react-spring@^8.0.20, react-spring@^8.0.27:
"@babel/runtime" "^7.3.1" "@babel/runtime" "^7.3.1"
prop-types "^15.5.8" prop-types "^15.5.8"
react-time-picker@^4.2.0: react-time-picker@^4.4.2:
version "4.2.1" version "4.4.4"
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.2.1.tgz#b27f0bbc2e58534f20dbf10b14d0b8f3334fcb07" resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.4.4.tgz#a67ca5fd88f51eac0919df802e416d9a25ad726a"
integrity sha512-T0aEabJ3bz54l8LV3pdpB5lOZuO3pRIbry5STcUV58UndlrWLcHpdpvS1IC8JLNXhbLxzGs1MmpASb5k1ddlsg== integrity sha512-WMdrpGnegug0871Do+SU1Fe91uZGmS6JUo1Yw7eLfU3VHMXCFj9sL9FAT6BuXe7lfILBbXq4tQQOqa/rLDASQg==
dependencies: dependencies:
"@wojtekmaj/date-utils" "^1.0.0" "@wojtekmaj/date-utils" "^1.0.0"
get-user-locale "^1.2.0" get-user-locale "^1.2.0"
make-event-props "^1.1.0" make-event-props "^1.1.0"
merge-class-names "^1.1.1" merge-class-names "^1.1.1"
merge-refs "^1.0.0"
prop-types "^15.6.0" prop-types "^15.6.0"
react-clock "^3.0.0" react-clock "^3.0.0"
react-fit "^1.0.3" react-fit "^1.0.3"
update-input-width "^1.1.1" update-input-width "^1.2.2"
react-top-loading-bar@^2.0.1: react-top-loading-bar@^2.0.1:
version "2.0.1" version "2.0.1"
@ -16705,10 +16712,10 @@ upath@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
update-input-width@^1.1.1: update-input-width@^1.2.2:
version "1.2.1" version "1.2.2"
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.1.tgz#769d6182413590c3b50b52ffa9c65d79e2c17f95" resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.2.tgz#9a6a35858ae8e66fbfe0304437b23a4934fc7d37"
integrity sha512-zygDshqDb2C2/kgfoD423n5htv/3OBF7aTaz2u2zZy998EJki8njOHOeZjKEd8XSYeDziIX1JXfMsKaIRJeJ/Q== integrity sha512-6QwD9ZVSXb96PxOZ01DU0DJTPwQGY7qBYgdniZKJN02Xzom2m+9J6EPxMbefskqtj4x78qbe5psDSALq9iNEYg==
update-notifier@^2.3.0, update-notifier@^2.5.0: update-notifier@^2.3.0, update-notifier@^2.5.0:
version "2.5.0" version "2.5.0"