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:
parent
f112721398
commit
038692cafc
53 changed files with 1245 additions and 378 deletions
19
flow-typed/livestream.js
vendored
19
flow-typed/livestream.js
vendored
|
@ -21,13 +21,26 @@ declare type LivestreamReplayItem = {
|
|||
}
|
||||
declare type LivestreamReplayData = Array<LivestreamReplayItem>;
|
||||
|
||||
declare type CurrentLiveClaim = {
|
||||
claimId: string | null,
|
||||
claimUri: string | null,
|
||||
}
|
||||
|
||||
declare type LivestreamChannelStatus = {
|
||||
channelId: null | string,
|
||||
isBroadcasting: boolean,
|
||||
liveClaim: CurrentLiveClaim,
|
||||
}
|
||||
|
||||
declare type LivestreamState = {
|
||||
fetchingById: {},
|
||||
viewersById: {},
|
||||
fetchingActiveLivestreams: boolean,
|
||||
fetchingActiveLivestreams: boolean | string,
|
||||
activeLivestreams: ?LivestreamInfo,
|
||||
activeLivestreamsLastFetchedDate: number,
|
||||
activeLivestreamsLastFetchedOptions: {},
|
||||
|
||||
currentChannelStatus: LivestreamChannelStatus,
|
||||
}
|
||||
|
||||
declare type LivestreamInfo = {
|
||||
|
@ -35,7 +48,7 @@ declare type LivestreamInfo = {
|
|||
live: boolean,
|
||||
viewCount: number,
|
||||
creatorId: string,
|
||||
latestClaimId: string,
|
||||
latestClaimUri: string,
|
||||
claimId: string,
|
||||
claimUri: string,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
"player.js": "^0.1.0",
|
||||
"proxy-polyfill": "0.1.6",
|
||||
"re-reselect": "^4.0.0",
|
||||
"react-datetime-picker": "^3.2.1",
|
||||
"react-datetime-picker": "^3.4.3",
|
||||
"react-plastic": "^1.1.1",
|
||||
"react-top-loading-bar": "^2.0.1",
|
||||
"remove-markdown": "^0.3.0",
|
||||
|
|
|
@ -1795,7 +1795,6 @@
|
|||
"Create A LiveStream": "Create A LiveStream",
|
||||
"%channel% has disabled chat for this stream. Enjoy the stream!": "%channel% has disabled chat for this stream. Enjoy the stream!",
|
||||
"This channel has disabled chat for this stream. Enjoy the stream!": "This channel has disabled chat for this stream. Enjoy the stream!",
|
||||
"%channel% isn't live right now, but the chat is! Check back later to watch the stream.": "%channel% isn't live right now, but the chat is! Check back later to watch the stream.",
|
||||
"This channel isn't live right now, but the chat is! Check back later to watch the stream.": "This channel isn't live right now, but the chat is! Check back later to watch the stream.",
|
||||
"Right now": "Right now",
|
||||
"%viewer_count% currently watching": "%viewer_count% currently watching",
|
||||
|
@ -1805,7 +1804,7 @@
|
|||
"Livestream": "Livestream",
|
||||
"Your stream key": "Your stream key",
|
||||
"Stream server": "Stream server",
|
||||
"Stream key": "Stream key",
|
||||
"Stream key (can be reused)": "Stream key (can be reused)",
|
||||
"Your livestream uploads": "Your livestream uploads",
|
||||
"Your pending livestream uploads": "Your pending livestream uploads",
|
||||
"No livestream publishes found": "No livestream publishes found",
|
||||
|
@ -2207,5 +2206,10 @@
|
|||
"Cookies": "Cookies",
|
||||
"Did someone invite you to use Odysee? Tell us who and you both get a reward!": "Did someone invite you to use Odysee? Tell us who and you both get a reward!",
|
||||
"There are unsaved settings. Exit the Settings Page to finalize them.": "There are unsaved settings. Exit the Settings Page to finalize them.",
|
||||
"For streaming from your mobile device, we recommend PRISM Live Studio from the app store.": "For streaming from your mobile device, we recommend PRISM Live Studio from the app store.",
|
||||
"Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.": "Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.",
|
||||
"Confirmation process takes a few minutes, but then you can go live anytime. The stream is not shown anywhere until you are broadcasting.": "Confirmation process takes a few minutes, but then you can go live anytime. The stream is not shown anywhere until you are broadcasting.",
|
||||
"Your scheduled streams will appear on your channel page and for your followers. Chat will not be active until 5 minutes before the start time.": "Your scheduled streams will appear on your channel page and for your followers. Chat will not be active until 5 minutes before the start time.",
|
||||
"Update or Publish Replay": "Update or Publish Replay",
|
||||
"--end--": "--end--"
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
|
|||
import { withRouter } from 'react-router';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings';
|
||||
import { doFetchActiveLivestream } from 'redux/actions/livestream';
|
||||
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
|
||||
|
||||
import ChannelContent from './view';
|
||||
|
||||
|
@ -32,11 +34,13 @@ const select = (state, props) => {
|
|||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
showMature: selectShowMatureContent(state),
|
||||
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
|
||||
currentChannelStatus: selectCurrentChannelStatus(state),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doResolveUris: (uris, returnCachedUris) => dispatch(doResolveUris(uris, returnCachedUris)),
|
||||
doFetchActiveLivestream: (channelID) => dispatch(doFetchActiveLivestream(channelID)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(ChannelContent));
|
||||
|
|
|
@ -13,6 +13,7 @@ import LivestreamLink from 'component/livestreamLink';
|
|||
import { Form, FormField } from 'component/common/form';
|
||||
import { DEBOUNCE_WAIT_DURATION_MS } from 'constants/search';
|
||||
import { lighthouse } from 'redux/actions/search';
|
||||
import ScheduledStreams from 'component/scheduledStreams';
|
||||
|
||||
const TYPES_TO_ALLOW_FILTER = ['stream', 'repost'];
|
||||
|
||||
|
@ -36,6 +37,8 @@ type Props = {
|
|||
doResolveUris: (Array<string>, boolean) => void,
|
||||
claimType: string,
|
||||
empty?: string,
|
||||
doFetchActiveLivestream: (string) => void,
|
||||
currentChannelStatus: LivestreamChannelStatus,
|
||||
};
|
||||
|
||||
function ChannelContent(props: Props) {
|
||||
|
@ -55,6 +58,8 @@ function ChannelContent(props: Props) {
|
|||
doResolveUris,
|
||||
claimType,
|
||||
empty,
|
||||
doFetchActiveLivestream,
|
||||
currentChannelStatus,
|
||||
} = props;
|
||||
// const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
|
||||
const claimsInChannel = 9999;
|
||||
|
@ -65,6 +70,7 @@ function ChannelContent(props: Props) {
|
|||
} = useHistory();
|
||||
const url = `${pathname}${search}`;
|
||||
const claimId = claim && claim.claim_id;
|
||||
const isChannelEmpty = !claim || !claim.meta;
|
||||
const showFilters =
|
||||
!claimType ||
|
||||
(Array.isArray(claimType)
|
||||
|
@ -239,20 +245,52 @@ function ChannelContent(props: Props) {
|
|||
}
|
||||
}, DEBOUNCE_WAIT_DURATION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [claimId, searchQuery, showMature]);
|
||||
}, [claimId, searchQuery, showMature, doResolveUris]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchQuery('');
|
||||
setSearchResults(null);
|
||||
}, [url]);
|
||||
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
const [isChannelBroadcasting, setIsChannelBroadcasting] = React.useState(false);
|
||||
|
||||
// Find out current channels status + active live claim.
|
||||
React.useEffect(() => {
|
||||
doFetchActiveLivestream(claimId);
|
||||
const intervalId = setInterval(() => doFetchActiveLivestream(claimId), 30000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [claimId, doFetchActiveLivestream]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initialized = currentChannelStatus.channelId === claimId;
|
||||
setIsInitialized(initialized);
|
||||
if (initialized) {
|
||||
setIsChannelBroadcasting(currentChannelStatus.isBroadcasting);
|
||||
}
|
||||
}, [currentChannelStatus, claimId]);
|
||||
|
||||
const showScheduledLiveStreams = claimType !== 'collection'; // ie. not on the playlist page.
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!fetching && Boolean(claimsInChannel) && !channelIsBlocked && !channelIsBlackListed && (
|
||||
<HiddenNsfwClaims uri={uri} />
|
||||
)}
|
||||
|
||||
<LivestreamLink uri={uri} />
|
||||
{!fetching && isInitialized && isChannelBroadcasting && !isChannelEmpty && (
|
||||
<LivestreamLink claimUri={currentChannelStatus.liveClaim.claimUri} />
|
||||
)}
|
||||
|
||||
{!fetching && showScheduledLiveStreams && (
|
||||
<ScheduledStreams
|
||||
channelIds={[claimId]}
|
||||
tileLayout={tileLayout}
|
||||
liveUris={
|
||||
isChannelBroadcasting && currentChannelStatus.liveClaim ? [currentChannelStatus.liveClaim.claimUri] : []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!fetching && channelIsBlackListed && (
|
||||
<section className="card card--section">
|
||||
|
@ -277,6 +315,7 @@ function ChannelContent(props: Props) {
|
|||
|
||||
<Ads type="homepage" />
|
||||
|
||||
{!fetching && (
|
||||
<ClaimListDiscover
|
||||
hasSource
|
||||
defaultFreshness={CS.FRESH_ALL}
|
||||
|
@ -313,6 +352,7 @@ function ChannelContent(props: Props) {
|
|||
channelIsMine={channelIsMine}
|
||||
empty={empty}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -44,6 +44,9 @@ type Props = {
|
|||
collectionId?: string,
|
||||
showNoSourceClaims?: boolean,
|
||||
onClick?: (e: any, claim?: ?Claim, index?: number) => void,
|
||||
maxClaimRender?: number,
|
||||
excludeUris?: Array<string>,
|
||||
loadedCallback?: (number) => void,
|
||||
};
|
||||
|
||||
export default function ClaimList(props: Props) {
|
||||
|
@ -74,6 +77,9 @@ export default function ClaimList(props: Props) {
|
|||
collectionId,
|
||||
showNoSourceClaims,
|
||||
onClick,
|
||||
maxClaimRender,
|
||||
excludeUris = [],
|
||||
loadedCallback,
|
||||
} = props;
|
||||
|
||||
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
|
||||
|
@ -83,8 +89,15 @@ export default function ClaimList(props: Props) {
|
|||
const timedOut = uris === null;
|
||||
const urisLength = (uris && uris.length) || 0;
|
||||
|
||||
const tileUris = (prefixUris || []).concat(uris);
|
||||
const sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || [];
|
||||
let tileUris = (prefixUris || []).concat(uris || []);
|
||||
tileUris = tileUris.filter((uri) => !excludeUris.includes(uri));
|
||||
if (maxClaimRender) tileUris = tileUris.slice(0, maxClaimRender);
|
||||
|
||||
let sortedUris = (urisLength > 0 && (currentSort === SORT_NEW ? tileUris : tileUris.slice().reverse())) || [];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof loadedCallback === 'function') loadedCallback(tileUris.length || 0);
|
||||
}, [tileUris.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const noResultMsg = searchInLanguage
|
||||
? __('No results. Contents may be hidden by the Language filter.')
|
||||
|
|
5
ui/component/claimListDiscover/context.js
Normal file
5
ui/component/claimListDiscover/context.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
const ClaimListDiscoverContext = createContext();
|
||||
|
||||
export default ClaimListDiscoverContext;
|
|
@ -94,6 +94,12 @@ type Props = {
|
|||
// --- perform ---
|
||||
doClaimSearch: ({}) => void,
|
||||
doFetchViewCount: (claimIdCsv: string) => void,
|
||||
|
||||
hideLayoutButton?: boolean,
|
||||
loadedCallback?: (number) => void,
|
||||
maxClaimRender?: number,
|
||||
useSkeletonScreen?: boolean,
|
||||
excludeUris?: Array<string>,
|
||||
};
|
||||
|
||||
function ClaimListDiscover(props: Props) {
|
||||
|
@ -158,6 +164,11 @@ function ClaimListDiscover(props: Props) {
|
|||
empty,
|
||||
claimsByUri,
|
||||
doFetchViewCount,
|
||||
hideLayoutButton = false,
|
||||
loadedCallback,
|
||||
maxClaimRender,
|
||||
useSkeletonScreen = true,
|
||||
excludeUris = [],
|
||||
} = props;
|
||||
const didNavigateForward = history.action === 'PUSH';
|
||||
const { search } = location;
|
||||
|
@ -515,12 +526,21 @@ function ClaimListDiscover(props: Props) {
|
|||
}
|
||||
|
||||
function resolveOrderByOption(orderBy: string | Array<string>, sortBy: string | Array<string>) {
|
||||
const order_by =
|
||||
orderBy === CS.ORDER_BY_TRENDING
|
||||
? CS.ORDER_BY_TRENDING_VALUE
|
||||
: orderBy === CS.ORDER_BY_NEW
|
||||
? CS.ORDER_BY_NEW_VALUE
|
||||
: CS.ORDER_BY_TOP_VALUE;
|
||||
let order_by;
|
||||
|
||||
switch (orderBy) {
|
||||
case CS.ORDER_BY_TRENDING:
|
||||
order_by = CS.ORDER_BY_TRENDING_VALUE;
|
||||
break;
|
||||
case CS.ORDER_BY_NEW:
|
||||
order_by = CS.ORDER_BY_NEW_VALUE;
|
||||
break;
|
||||
case CS.ORDER_BY_NEW_ASC:
|
||||
order_by = CS.ORDER_BY_NEW_ASC_VALUE;
|
||||
break;
|
||||
default:
|
||||
order_by = CS.ORDER_BY_TOP_VALUE;
|
||||
}
|
||||
|
||||
if (orderBy === CS.ORDER_BY_NEW && sortBy === CS.SORT_BY.OLDEST.key) {
|
||||
return order_by.map((x) => `${CS.SORT_BY.OLDEST.opt}${x}`);
|
||||
|
@ -578,6 +598,7 @@ function ClaimListDiscover(props: Props) {
|
|||
hiddenNsfwMessage={hiddenNsfwMessage}
|
||||
setPage={setPage}
|
||||
tileLayout={tileLayout}
|
||||
hideLayoutButton={hideLayoutButton}
|
||||
hideFilters={hideFilters}
|
||||
scrollAnchor={scrollAnchor}
|
||||
/>
|
||||
|
@ -610,8 +631,11 @@ function ClaimListDiscover(props: Props) {
|
|||
searchOptions={options}
|
||||
showNoSourceClaims={showNoSourceClaims}
|
||||
empty={empty}
|
||||
maxClaimRender={maxClaimRender}
|
||||
excludeUris={excludeUris}
|
||||
loadedCallback={loadedCallback}
|
||||
/>
|
||||
{loading && (
|
||||
{loading && useSkeletonScreen && (
|
||||
<div className="claim-grid">
|
||||
{new Array(dynamicPageSize).fill(1).map((x, i) => (
|
||||
<ClaimPreviewTile key={i} placeholder="loading" />
|
||||
|
@ -643,8 +667,12 @@ function ClaimListDiscover(props: Props) {
|
|||
searchOptions={options}
|
||||
showNoSourceClaims={hasNoSource || showNoSourceClaims}
|
||||
empty={empty}
|
||||
maxClaimRender={maxClaimRender}
|
||||
excludeUris={excludeUris}
|
||||
loadedCallback={loadedCallback}
|
||||
/>
|
||||
{loading &&
|
||||
useSkeletonScreen &&
|
||||
new Array(dynamicPageSize)
|
||||
.fill(1)
|
||||
.map((x, i) => (
|
||||
|
|
|
@ -24,6 +24,7 @@ type Props = {
|
|||
orderBy?: Array<string>,
|
||||
defaultOrderBy?: string,
|
||||
hideAdvancedFilter: boolean,
|
||||
hideLayoutButton: boolean,
|
||||
hasMatureTags: boolean,
|
||||
hiddenNsfwMessage?: Node,
|
||||
channelIds?: Array<string>,
|
||||
|
@ -49,6 +50,7 @@ function ClaimListHeader(props: Props) {
|
|||
orderBy,
|
||||
defaultOrderBy,
|
||||
hideAdvancedFilter,
|
||||
hideLayoutButton,
|
||||
hasMatureTags,
|
||||
hiddenNsfwMessage,
|
||||
channelIds,
|
||||
|
@ -269,7 +271,7 @@ function ClaimListHeader(props: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{tileLayout !== undefined && (
|
||||
{tileLayout !== undefined && !hideLayoutButton && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
doSetClientSetting(SETTINGS.TILE_LAYOUT, !tileLayout);
|
||||
|
|
|
@ -5,16 +5,18 @@ import { doClearPublish, doPrepareEdit } from 'redux/actions/publish';
|
|||
import { push } from 'connected-react-router';
|
||||
import ClaimPreviewSubtitle from './view';
|
||||
import { doFetchSubCount, selectSubCountForUri } from 'lbryinc';
|
||||
import { selectIsActiveLivestreamForUri } from 'redux/selectors/livestream';
|
||||
|
||||
const select = (state, props) => {
|
||||
const claim = selectClaimForUri(state, props.uri);
|
||||
const isChannel = claim && claim.value_type === 'channel';
|
||||
|
||||
const isLivestream = isStreamPlaceholderClaim(claim);
|
||||
return {
|
||||
claim,
|
||||
pending: makeSelectClaimIsPending(props.uri)(state),
|
||||
isLivestream: isStreamPlaceholderClaim(claim),
|
||||
isLivestream,
|
||||
subCount: isChannel ? selectSubCountForUri(state, props.uri) : 0,
|
||||
isLivestreamActive: isLivestream && selectIsActiveLivestreamForUri(state, props.uri),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,26 +1,29 @@
|
|||
// @flow
|
||||
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import UriIndicator from 'component/uriIndicator';
|
||||
import DateTime from 'component/dateTime';
|
||||
import Button from 'component/button';
|
||||
import FileViewCountInline from 'component/fileViewCountInline';
|
||||
import { parseURI } from 'util/lbryURI';
|
||||
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
|
||||
import moment from 'moment';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: ?Claim,
|
||||
claim: ?StreamClaim,
|
||||
pending?: boolean,
|
||||
type: string,
|
||||
beginPublish: (?string) => void,
|
||||
isLivestream: boolean,
|
||||
fetchSubCount: (string) => void,
|
||||
subCount: number,
|
||||
isLivestreamActive: boolean,
|
||||
};
|
||||
|
||||
// previews used in channel overview and homepage (and other places?)
|
||||
function ClaimPreviewSubtitle(props: Props) {
|
||||
const { pending, uri, claim, type, beginPublish, isLivestream, fetchSubCount, subCount } = props;
|
||||
const { pending, uri, claim, type, beginPublish, isLivestream, isLivestreamActive, fetchSubCount, subCount } = props;
|
||||
const isChannel = claim && claim.value_type === 'channel';
|
||||
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
|
||||
|
||||
|
@ -38,6 +41,29 @@ function ClaimPreviewSubtitle(props: Props) {
|
|||
({ streamName: name } = parseURI(uri));
|
||||
} catch (e) {}
|
||||
|
||||
const { listingType } = useContext(ClaimListDiscoverContext) || {};
|
||||
|
||||
const LivestreamDateTimeLabel = () => {
|
||||
// If showing in upcoming and in the past. (we allow x time in past to show here if not live yet)
|
||||
if (listingType === 'UPCOMING') {
|
||||
// $FlowFixMe
|
||||
if (moment.unix(claim.value.release_time).isBefore()) {
|
||||
return __('Starting Soon');
|
||||
}
|
||||
} else {
|
||||
// If not in upcoming + live and in the future (started streaming a bit early)
|
||||
// $FlowFixMe
|
||||
if (isLivestreamActive && moment.unix(claim.value.release_time).isAfter()) {
|
||||
return __('Streaming Now');
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{__('Livestream')} <DateTime timeAgo uri={uri} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="media__subtitle">
|
||||
{claim ? (
|
||||
|
@ -56,7 +82,7 @@ function ClaimPreviewSubtitle(props: Props) {
|
|||
|
||||
{!isChannel &&
|
||||
(isLivestream && ENABLE_NO_SOURCE_CLAIMS ? (
|
||||
__('Livestream')
|
||||
<LivestreamDateTimeLabel />
|
||||
) : (
|
||||
<>
|
||||
<FileViewCountInline uri={uri} isLivestream={isLivestream} />
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
import FileThumbnail from 'component/fileThumbnail';
|
||||
|
@ -19,6 +19,8 @@ import FileWatchLaterLink from 'component/fileWatchLaterLink';
|
|||
import ClaimRepostAuthor from 'component/claimRepostAuthor';
|
||||
import ClaimMenuList from 'component/claimMenuList';
|
||||
import CollectionPreviewOverlay from 'component/collectionPreviewOverlay';
|
||||
import ClaimListDiscoverContext from 'component/claimListDiscover/context';
|
||||
import moment from 'moment';
|
||||
// $FlowFixMe cannot resolve ...
|
||||
import PlaceholderTx from 'static/img/placeholderTx.gif';
|
||||
|
||||
|
@ -108,6 +110,8 @@ function ClaimPreviewTile(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
const { listingType } = useContext(ClaimListDiscoverContext) || {};
|
||||
|
||||
const signingChannel = claim && claim.signing_channel;
|
||||
const isChannel = claim && claim.value_type === 'channel';
|
||||
const channelUri = !isChannel ? signingChannel && signingChannel.permanent_url : claim && claim.permanent_url;
|
||||
|
@ -167,10 +171,27 @@ function ClaimPreviewTile(props: Props) {
|
|||
}
|
||||
|
||||
let liveProperty = null;
|
||||
if (isLivestreamActive === true) {
|
||||
if (isLivestream === true) {
|
||||
liveProperty = (claim) => <>LIVE</>;
|
||||
}
|
||||
|
||||
const LivestreamDateTimeLabel = () => {
|
||||
// If showing in upcoming and in the past. (we allow x time in past to show here if not live yet)
|
||||
if (listingType === 'UPCOMING') {
|
||||
// $FlowFixMe
|
||||
if (moment.unix(claim.value.release_time).isBefore()) {
|
||||
return __('Starting Soon');
|
||||
}
|
||||
} else {
|
||||
// If not in upcoming + live and in the future (started streaming a bit early)
|
||||
// $FlowFixMe
|
||||
if (isLivestreamActive && moment.unix(claim.value.release_time).isAfter()) {
|
||||
return __('Streaming Now');
|
||||
}
|
||||
}
|
||||
return <DateTime timeAgo uri={uri} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
onClick={handleClick}
|
||||
|
@ -239,7 +260,8 @@ function ClaimPreviewTile(props: Props) {
|
|||
<UriIndicator uri={uri} link />
|
||||
<div className="claim-tile__about--counts">
|
||||
<FileViewCountInline uri={uri} isLivestream={isLivestream} />
|
||||
<DateTime timeAgo uri={uri} />
|
||||
{isLivestream && <LivestreamDateTimeLabel />}
|
||||
{!isLivestream && <DateTime timeAgo uri={uri} />}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
|
|
@ -151,7 +151,7 @@ function FileActions(props: Props) {
|
|||
<Button
|
||||
className="button--file-action"
|
||||
icon={ICONS.EDIT}
|
||||
label={isLivestreamClaim ? __('Update') : __('Edit')}
|
||||
label={isLivestreamClaim ? __('Update or Publish Replay') : __('Edit')}
|
||||
navigate={`/$/${PAGES.UPLOAD}`}
|
||||
onClick={() => {
|
||||
prepareEdit(claim, editUri, fileInfo);
|
||||
|
|
|
@ -18,7 +18,7 @@ function FileSubtitle(props: Props) {
|
|||
<>
|
||||
<div className="media__subtitle--between">
|
||||
<div className="file__viewdate">
|
||||
{livestream ? <span>{__('Right now')}</span> : <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
|
||||
{livestream && !isLive && <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
|
||||
|
||||
<FileViewCount uri={uri} livestream={livestream} isLive={isLive} />
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,6 @@ import { connect } from 'react-redux';
|
|||
import { selectClaimIdForUri } from 'redux/selectors/claims';
|
||||
import { selectViewersForId } from 'redux/selectors/livestream';
|
||||
import { doFetchViewCount, selectViewCountForUri } from 'lbryinc';
|
||||
import { doAnalyticsView } from 'redux/actions/app';
|
||||
import FileViewCount from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
|
@ -16,7 +15,6 @@ const select = (state, props) => {
|
|||
|
||||
const perform = (dispatch) => ({
|
||||
fetchViewCount: (claimId) => dispatch(doFetchViewCount(claimId)),
|
||||
doAnalyticsView: (uri) => dispatch(doAnalyticsView(uri)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(FileViewCount);
|
||||
|
|
|
@ -12,25 +12,17 @@ type Props = {
|
|||
uri: string,
|
||||
viewCount: string,
|
||||
activeViewers?: number,
|
||||
doAnalyticsView: (string) => void,
|
||||
};
|
||||
|
||||
function FileViewCount(props: Props) {
|
||||
const { claimId, uri, fetchViewCount, viewCount, livestream, activeViewers, isLive = false, doAnalyticsView } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (livestream) {
|
||||
// Regular claims will call the file/view event when a user actually watches the claim
|
||||
// This can be removed when we get rid of the livestream iframe
|
||||
doAnalyticsView(uri);
|
||||
}
|
||||
}, [livestream, doAnalyticsView, uri]);
|
||||
const { claimId, fetchViewCount, viewCount, livestream, activeViewers, isLive = false } = props;
|
||||
|
||||
// @Note: it's important this only runs once on initial render.
|
||||
React.useEffect(() => {
|
||||
if (claimId) {
|
||||
fetchViewCount(claimId);
|
||||
}
|
||||
}, [fetchViewCount, uri, claimId]);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const formattedViewCount = Number(viewCount).toLocaleString();
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import { connect } from 'react-redux';
|
|||
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
|
||||
import { doResolveUris } from 'redux/actions/claims';
|
||||
import { selectClaimForUri, selectMyClaimIdsRaw } from 'redux/selectors/claims';
|
||||
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
|
||||
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
|
||||
import {
|
||||
selectTopLevelCommentsForUri,
|
||||
|
@ -25,8 +24,6 @@ const select = (state, props) => ({
|
|||
});
|
||||
|
||||
export default connect(select, {
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doCommentList,
|
||||
doSuperChatList,
|
||||
doResolveUris,
|
||||
|
|
|
@ -17,8 +17,6 @@ type Props = {
|
|||
uri: string,
|
||||
claim: ?StreamClaim,
|
||||
embed?: boolean,
|
||||
doCommentSocketConnect: (string, string) => void,
|
||||
doCommentSocketDisconnect: (string) => void,
|
||||
doCommentList: (string, string, number, number) => void,
|
||||
comments: Array<Comment>,
|
||||
pinnedComments: Array<Comment>,
|
||||
|
@ -39,8 +37,6 @@ export default function LivestreamComments(props: Props) {
|
|||
claim,
|
||||
uri,
|
||||
embed,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
comments: commentsByChronologicalOrder,
|
||||
pinnedComments,
|
||||
doCommentList,
|
||||
|
@ -99,15 +95,8 @@ export default function LivestreamComments(props: Props) {
|
|||
if (claimId) {
|
||||
doCommentList(uri, '', 1, 75);
|
||||
doSuperChatList(uri);
|
||||
doCommentSocketConnect(uri, claimId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (claimId) {
|
||||
doCommentSocketDisconnect(claimId);
|
||||
}
|
||||
};
|
||||
}, [claimId, uri, doCommentList, doSuperChatList, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||
}, [claimId, uri, doCommentList, doSuperChatList]);
|
||||
|
||||
// Register scroll handler (TODO: Should throttle/debounce)
|
||||
React.useEffect(() => {
|
||||
|
|
|
@ -3,19 +3,36 @@ import { LIVESTREAM_EMBED_URL } from 'constants/livestream';
|
|||
import React from 'react';
|
||||
import FileTitleSection from 'component/fileTitleSection';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import LivestreamScheduledInfo from 'component/livestreamScheduledInfo';
|
||||
import classnames from 'classnames';
|
||||
import { lazyImport } from 'util/lazyImport';
|
||||
import LivestreamLink from 'component/livestreamLink';
|
||||
|
||||
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
claim: ?StreamClaim,
|
||||
isLive: boolean,
|
||||
chatDisabled: boolean,
|
||||
hideComments: boolean,
|
||||
release: any,
|
||||
showLivestream: boolean,
|
||||
showScheduledInfo: boolean,
|
||||
isCurrentClaimLive: boolean,
|
||||
activeStreamUri: boolean | string,
|
||||
};
|
||||
|
||||
export default function LivestreamLayout(props: Props) {
|
||||
const { claim, uri, isLive, chatDisabled } = props;
|
||||
const {
|
||||
claim,
|
||||
uri,
|
||||
hideComments,
|
||||
release,
|
||||
showLivestream,
|
||||
showScheduledInfo,
|
||||
isCurrentClaimLive,
|
||||
activeStreamUri,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!claim || !claim.signing_channel) {
|
||||
|
@ -28,17 +45,24 @@ export default function LivestreamLayout(props: Props) {
|
|||
return (
|
||||
<>
|
||||
<div className="section card-stack">
|
||||
<div className="file-render file-render--video livestream">
|
||||
<div
|
||||
className={classnames('file-render file-render--video livestream', {
|
||||
'file-render--scheduledLivestream': !showLivestream,
|
||||
})}
|
||||
>
|
||||
<div className="file-viewer">
|
||||
{showLivestream && (
|
||||
<iframe
|
||||
src={`${LIVESTREAM_EMBED_URL}/${channelClaimId}?skin=odysee&autoplay=1`}
|
||||
scrolling="no"
|
||||
allowFullScreen
|
||||
/>
|
||||
)}
|
||||
{showScheduledInfo && <LivestreamScheduledInfo release={release} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Boolean(chatDisabled) && (
|
||||
{hideComments && !showScheduledInfo && (
|
||||
<div className="help--notice">
|
||||
{channelName
|
||||
? __('%channel% has disabled chat for this stream. Enjoy the stream!', { channel: channelName })
|
||||
|
@ -46,7 +70,7 @@ export default function LivestreamLayout(props: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!isLive && (
|
||||
{!activeStreamUri && !showScheduledInfo && !isCurrentClaimLive && (
|
||||
<div className="help--notice">
|
||||
{channelName
|
||||
? __("%channelName% isn't live right now, but the chat is! Check back later to watch the stream.", {
|
||||
|
@ -56,9 +80,11 @@ export default function LivestreamLayout(props: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<React.Suspense fallback={null}>{isMobile && <LivestreamComments uri={uri} />}</React.Suspense>
|
||||
{activeStreamUri && <LivestreamLink claimUri={activeStreamUri} />}
|
||||
|
||||
<FileTitleSection uri={uri} livestream isLive={isLive} />
|
||||
<React.Suspense fallback={null}>{isMobile && !hideComments && <LivestreamComments uri={uri} />}</React.Suspense>
|
||||
|
||||
<FileTitleSection uri={uri} livestream isLive={showLivestream} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectClaimForUri } from 'redux/selectors/claims';
|
||||
|
||||
import LivestreamLink from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
channelClaim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
|
||||
export default connect(select)(LivestreamLink);
|
||||
export default connect()(LivestreamLink);
|
||||
|
|
|
@ -1,72 +1,30 @@
|
|||
// @flow
|
||||
import * as CS from 'constants/claim_search';
|
||||
|
||||
import React from 'react';
|
||||
import Card from 'component/common/card';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
import Lbry from 'lbry';
|
||||
import { useHistory } from 'react-router';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
|
||||
|
||||
type Props = {
|
||||
channelClaim: ChannelClaim,
|
||||
claimUri: string,
|
||||
};
|
||||
|
||||
export default function LivestreamLink(props: Props) {
|
||||
const { channelClaim } = props;
|
||||
const { claimUri } = props;
|
||||
const { push } = useHistory();
|
||||
const [livestreamClaim, setLivestreamClaim] = React.useState(false);
|
||||
const [isLivestreaming, setIsLivestreaming] = React.useState(false);
|
||||
const livestreamChannelId = (channelClaim && channelClaim.claim_id) || ''; // TODO: fail in a safer way, probably
|
||||
// TODO: pput this back when hubs claims_in_channel are fixed
|
||||
const isChannelEmpty = !channelClaim || !channelClaim.meta;
|
||||
// ||
|
||||
// !channelClaim.meta.claims_in_channel;
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't search empty channels
|
||||
if (livestreamChannelId && !isChannelEmpty) {
|
||||
Lbry.claim_search({
|
||||
channel_ids: [livestreamChannelId],
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
no_totals: true,
|
||||
has_no_source: true,
|
||||
claim_type: ['stream'],
|
||||
order_by: CS.ORDER_BY_NEW_VALUE,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.items && res.items.length > 0) {
|
||||
const claim = res.items[0];
|
||||
// $FlowFixMe Too many Claim GenericClaim etc types.
|
||||
setLivestreamClaim(claim);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [livestreamChannelId, isChannelEmpty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!livestreamClaim) return;
|
||||
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLivestreaming(state));
|
||||
}, [livestreamChannelId, setIsLivestreaming, livestreamClaim]);
|
||||
|
||||
if (!livestreamClaim || !isLivestreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// gonna pass the wrapper in so I don't have to rewrite the dmca/blocking logic in claimPreview.
|
||||
const element = (props: { children: any }) => (
|
||||
<Card
|
||||
className="livestream__channel-link claim-preview__live"
|
||||
title={__('Live stream in progress')}
|
||||
onClick={() => {
|
||||
push(formatLbryUrlForWeb(livestreamClaim.canonical_url));
|
||||
push(formatLbryUrlForWeb(claimUri));
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return <ClaimPreview uri={livestreamClaim.canonical_url} wrapperElement={element} type="inline" />;
|
||||
return claimUri && <ClaimPreview uri={claimUri} wrapperElement={element} type="inline" />;
|
||||
}
|
||||
|
|
4
ui/component/livestreamScheduledInfo/index.js
Normal file
4
ui/component/livestreamScheduledInfo/index.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { connect } from 'react-redux';
|
||||
import LivestreamScheduledInfo from './view';
|
||||
|
||||
export default connect()(LivestreamScheduledInfo);
|
49
ui/component/livestreamScheduledInfo/view.jsx
Normal file
49
ui/component/livestreamScheduledInfo/view.jsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
|
@ -29,6 +29,7 @@ type Props = {
|
|||
needsYTAuth: boolean,
|
||||
fetchAccessToken: () => void,
|
||||
accessToken: string,
|
||||
isLivestreamMode: boolean,
|
||||
};
|
||||
|
||||
function PublishAdditionalOptions(props: Props) {
|
||||
|
@ -39,6 +40,7 @@ function PublishAdditionalOptions(props: Props) {
|
|||
otherLicenseDescription,
|
||||
licenseUrl,
|
||||
updatePublishForm,
|
||||
isLivestreamMode,
|
||||
// user,
|
||||
// useLBRYUploader,
|
||||
// needsYTAuth,
|
||||
|
@ -154,7 +156,7 @@ function PublishAdditionalOptions(props: Props) {
|
|||
)} */}
|
||||
{/* @endif */}
|
||||
<div className="section">
|
||||
<PublishReleaseDate />
|
||||
{!isLivestreamMode && <PublishReleaseDate />}
|
||||
|
||||
<FormField
|
||||
label={__('Language')}
|
||||
|
|
|
@ -31,6 +31,7 @@ import { useHistory } from 'react-router';
|
|||
import Spinner from 'component/spinner';
|
||||
import { toHex } from 'util/hex';
|
||||
import { LIVESTREAM_REPLAY_API } from 'constants/livestream';
|
||||
import PublishStreamReleaseDate from 'component/publishStreamReleaseDate';
|
||||
|
||||
// @if TARGET='app'
|
||||
import fs from 'fs';
|
||||
|
@ -609,8 +610,12 @@ function PublishForm(props: Props) {
|
|||
|
||||
{!publishing && (
|
||||
<div className={classnames({ 'card--disabled': formDisabled })}>
|
||||
{isLivestreamMode && <Card className={'card--enable-overflow'} body={<PublishStreamReleaseDate />} />}
|
||||
|
||||
{mode !== PUBLISH_MODES.POST && <PublishDescription disabled={formDisabled} />}
|
||||
|
||||
<Card actions={<SelectThumbnail livestreamdData={livestreamData} />} />
|
||||
|
||||
<TagsSelect
|
||||
suggestMature={!SIMPLE_SITE}
|
||||
disableAutoFocus
|
||||
|
@ -640,7 +645,8 @@ function PublishForm(props: Props) {
|
|||
|
||||
<PublishBid disabled={isStillEditing || formDisabled} />
|
||||
{!isLivestreamMode && <PublishPrice disabled={formDisabled} />}
|
||||
<PublishAdditionalOptions disabled={formDisabled} />
|
||||
|
||||
<PublishAdditionalOptions disabled={formDisabled} isLivestreamMode={isLivestreamMode} />
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
|
|
|
@ -19,18 +19,28 @@ type Props = {
|
|||
releaseTime: ?number,
|
||||
releaseTimeEdited: ?number,
|
||||
updatePublishForm: ({}) => void,
|
||||
allowDefault: ?boolean,
|
||||
showNowBtn: ?boolean,
|
||||
useMaxDate: ?boolean,
|
||||
};
|
||||
|
||||
const PublishReleaseDate = (props: Props) => {
|
||||
const { releaseTime, releaseTimeEdited, updatePublishForm } = props;
|
||||
const maxDate = new Date();
|
||||
const {
|
||||
releaseTime,
|
||||
releaseTimeEdited,
|
||||
updatePublishForm,
|
||||
allowDefault = true,
|
||||
showNowBtn = true,
|
||||
useMaxDate = true,
|
||||
} = props;
|
||||
const maxDate = useMaxDate ? new Date() : undefined;
|
||||
const [date, setDate] = React.useState(releaseTime ? linuxTimestampToDate(releaseTime) : new Date());
|
||||
|
||||
const isNew = releaseTime === undefined;
|
||||
const isEdit = !isNew;
|
||||
const isEdit = !isNew || allowDefault === false;
|
||||
|
||||
const showEditBtn = isNew && releaseTimeEdited === undefined;
|
||||
const showDefaultBtn = isNew && releaseTimeEdited !== undefined;
|
||||
const showEditBtn = isNew && releaseTimeEdited === undefined && allowDefault !== false;
|
||||
const showDefaultBtn = isNew && releaseTimeEdited !== undefined && allowDefault !== false;
|
||||
const showDatePicker = isEdit || releaseTimeEdited !== undefined;
|
||||
|
||||
const onDateTimePickerChanged = (value) => {
|
||||
|
@ -108,7 +118,7 @@ const PublishReleaseDate = (props: Props) => {
|
|||
onClick={() => newDate(RESET_TO_ORIGINAL)}
|
||||
/>
|
||||
)}
|
||||
{showDatePicker && (
|
||||
{showDatePicker && showNowBtn && (
|
||||
<Button
|
||||
button="link"
|
||||
label={__('Now')}
|
||||
|
|
14
ui/component/publishStreamReleaseDate/index.js
Normal file
14
ui/component/publishStreamReleaseDate/index.js
Normal 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);
|
96
ui/component/publishStreamReleaseDate/view.jsx
Normal file
96
ui/component/publishStreamReleaseDate/view.jsx
Normal 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;
|
4
ui/component/scheduledStreams/index.js
Normal file
4
ui/component/scheduledStreams/index.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ScheduledStreams from './view';
|
||||
|
||||
export default connect()(ScheduledStreams);
|
95
ui/component/scheduledStreams/view.jsx
Normal file
95
ui/component/scheduledStreams/view.jsx
Normal 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;
|
|
@ -475,6 +475,10 @@ export const FETCH_ACTIVE_LIVESTREAMS_FAILED = 'FETCH_ACTIVE_LIVESTREAMS_FAILED'
|
|||
export const FETCH_ACTIVE_LIVESTREAMS_SKIPPED = 'FETCH_ACTIVE_LIVESTREAMS_SKIPPED';
|
||||
export const FETCH_ACTIVE_LIVESTREAMS_COMPLETED = 'FETCH_ACTIVE_LIVESTREAMS_COMPLETED';
|
||||
|
||||
export const FETCH_ACTIVE_LIVESTREAM_FAILED = 'FETCH_ACTIVE_LIVESTREAMS_FAILED';
|
||||
export const FETCH_ACTIVE_LIVESTREAM_COMPLETED = 'FETCH_ACTIVE_LIVESTREAM_COMPLETED';
|
||||
export const FETCH_ACTIVE_LIVESTREAM_FINISHED = 'FETCH_ACTIVE_LIVESTREAM_FINISHED';
|
||||
|
||||
// Blacklist
|
||||
export const FETCH_BLACK_LISTED_CONTENT_STARTED = 'FETCH_BLACK_LISTED_CONTENT_STARTED';
|
||||
export const FETCH_BLACK_LISTED_CONTENT_COMPLETED = 'FETCH_BLACK_LISTED_CONTENT_COMPLETED';
|
||||
|
|
|
@ -29,10 +29,17 @@ export const FRESH_TYPES = [FRESH_DEFAULT, FRESH_DAY, FRESH_WEEK, FRESH_MONTH, F
|
|||
|
||||
export const ORDER_BY_TRENDING = 'trending';
|
||||
export const ORDER_BY_TRENDING_VALUE = ['trending_group', 'trending_mixed'];
|
||||
|
||||
export const ORDER_BY_TOP = 'top';
|
||||
export const ORDER_BY_TOP_VALUE = ['effective_amount'];
|
||||
|
||||
export const ORDER_BY_NEW = 'new';
|
||||
export const ORDER_BY_NEW_VALUE = ['release_time'];
|
||||
|
||||
export const ORDER_BY_NEW_ASC = 'new_asc';
|
||||
export const ORDER_BY_NEW_ASC_VALUE = ['^release_time'];
|
||||
|
||||
// @note: These are used to build the default controls available on claim listings.
|
||||
export const ORDER_BY_TYPES = [ORDER_BY_TRENDING, ORDER_BY_NEW, ORDER_BY_TOP];
|
||||
|
||||
export const DURATION_SHORT = 'short';
|
||||
|
|
|
@ -5,3 +5,7 @@ export const LIVESTREAM_RTMP_URL = 'rtmp://stream.odysee.com/live';
|
|||
export const LIVESTREAM_KILL = 'https://api.stream.odysee.com/stream/kill';
|
||||
|
||||
export const MAX_LIVESTREAM_COMMENTS = 50;
|
||||
|
||||
export const LIVESTREAM_STARTS_SOON_BUFFER = 5;
|
||||
export const LIVESTREAM_STARTED_RECENTLY_BUFFER = 15;
|
||||
export const LIVESTREAM_UPCOMING_BUFFER = 35;
|
||||
|
|
|
@ -142,6 +142,8 @@ const ModalPublishPreview = (props: Props) => {
|
|||
);
|
||||
}
|
||||
|
||||
const releasesInFuture = releaseTimeEdited && moment(releaseTimeEdited * 1000).isAfter();
|
||||
|
||||
const txFee = previewResponse ? previewResponse['total_fee'] : null;
|
||||
// $FlowFixMe add outputs[0] etc to PublishResponse type
|
||||
const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available;
|
||||
|
@ -153,7 +155,7 @@ const ModalPublishPreview = (props: Props) => {
|
|||
modalTitle = __('Confirm Edit');
|
||||
}
|
||||
} else if (livestream) {
|
||||
modalTitle = __('Create Livestream');
|
||||
modalTitle = releasesInFuture ? __('Schedule Livestream') : __('Create Livestream');
|
||||
} else {
|
||||
modalTitle = __('Confirm Upload');
|
||||
}
|
||||
|
@ -177,6 +179,8 @@ const ModalPublishPreview = (props: Props) => {
|
|||
}
|
||||
}
|
||||
|
||||
const releaseDateText = releasesInFuture ? __('Scheduled for') : __('Release date');
|
||||
|
||||
const descriptionValue = description ? (
|
||||
<div className="media__info-text-preview">
|
||||
<MarkdownPreview content={description} simpleLinks />
|
||||
|
@ -250,7 +254,7 @@ const ModalPublishPreview = (props: Props) => {
|
|||
{createRow(__('Deposit'), depositValue)}
|
||||
{createRow(__('Price'), priceValue)}
|
||||
{createRow(__('Language'), language)}
|
||||
{releaseTimeEdited && createRow(__('Release date'), releaseTimeStr(releaseTimeEdited))}
|
||||
{releaseTimeEdited && createRow(releaseDateText, releaseTimeStr(releaseTimeEdited))}
|
||||
{createRow(__('License'), licenseValue)}
|
||||
{createRow(__('Tags'), tagsValue)}
|
||||
</tbody>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import { doFetchActiveLivestreams } from 'redux/actions/livestream';
|
||||
import { selectActiveLivestreams } from 'redux/selectors/livestream';
|
||||
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
|
||||
import { selectSubscriptions } from 'redux/selectors/subscriptions';
|
||||
import { selectClientSetting } from 'redux/selectors/settings';
|
||||
|
||||
|
@ -11,6 +11,7 @@ const select = (state) => ({
|
|||
subscribedChannels: selectSubscriptions(state),
|
||||
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
|
||||
activeLivestreams: selectActiveLivestreams(state),
|
||||
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
|
||||
});
|
||||
|
||||
export default connect(select, {
|
||||
|
|
|
@ -11,28 +11,45 @@ import Button from 'component/button';
|
|||
import Icon from 'component/common/icon';
|
||||
import { splitBySeparator } from 'util/lbryURI';
|
||||
import { getLivestreamUris } from 'util/livestream';
|
||||
import ScheduledStreams from 'component/scheduledStreams';
|
||||
|
||||
type Props = {
|
||||
subscribedChannels: Array<Subscription>,
|
||||
tileLayout: boolean,
|
||||
activeLivestreams: ?LivestreamInfo,
|
||||
doFetchActiveLivestreams: () => void,
|
||||
fetchingActiveLivestreams: boolean,
|
||||
};
|
||||
|
||||
function ChannelsFollowingPage(props: Props) {
|
||||
const { subscribedChannels, tileLayout, activeLivestreams, doFetchActiveLivestreams } = props;
|
||||
const {
|
||||
subscribedChannels,
|
||||
tileLayout,
|
||||
activeLivestreams,
|
||||
doFetchActiveLivestreams,
|
||||
fetchingActiveLivestreams,
|
||||
} = props;
|
||||
|
||||
const hasSubsribedChannels = subscribedChannels.length > 0;
|
||||
const hasSubscribedChannels = subscribedChannels.length > 0;
|
||||
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
|
||||
|
||||
React.useEffect(() => {
|
||||
doFetchActiveLivestreams();
|
||||
}, []);
|
||||
|
||||
return !hasSubsribedChannels ? (
|
||||
return !hasSubscribedChannels ? (
|
||||
<ChannelsFollowingDiscoverPage />
|
||||
) : (
|
||||
<Page noFooter fullWidthPage={tileLayout}>
|
||||
{!fetchingActiveLivestreams && (
|
||||
<>
|
||||
<ScheduledStreams
|
||||
channelIds={channelIds}
|
||||
tileLayout={tileLayout}
|
||||
liveUris={getLivestreamUris(activeLivestreams, channelIds)}
|
||||
limitClaimsPerChannel={2}
|
||||
/>
|
||||
|
||||
<ClaimListDiscover
|
||||
prefixUris={getLivestreamUris(activeLivestreams, channelIds)}
|
||||
hideAdvancedFilter={SIMPLE_SITE}
|
||||
|
@ -57,6 +74,8 @@ function ChannelsFollowingPage(props: Props) {
|
|||
showNoSourceClaims={ENABLE_NO_SOURCE_CLAIMS}
|
||||
hasSource
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ type Props = {
|
|||
isAuthenticated: boolean,
|
||||
tileLayout: boolean,
|
||||
activeLivestreams: ?LivestreamInfo,
|
||||
doFetchActiveLivestreams: (orderBy?: Array<string>, pageSize?: number, forceFetch?: boolean) => void,
|
||||
doFetchActiveLivestreams: (orderBy?: Array<string>) => void,
|
||||
};
|
||||
|
||||
function DiscoverPage(props: Props) {
|
||||
|
@ -307,7 +307,7 @@ function DiscoverPage(props: Props) {
|
|||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sync liveSection --> liveSectionStore
|
||||
React.useEffect(() => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doFetchActiveLivestreams } from 'redux/actions/livestream';
|
||||
import { selectActiveLivestreams } from 'redux/selectors/livestream';
|
||||
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
|
||||
import { selectFollowedTags } from 'redux/selectors/tags';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectSubscriptions } from 'redux/selectors/subscriptions';
|
||||
|
@ -15,6 +15,7 @@ const select = (state) => ({
|
|||
showNsfw: selectShowMatureContent(state),
|
||||
homepageData: selectHomepageData(state),
|
||||
activeLivestreams: selectActiveLivestreams(state),
|
||||
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
|
|
|
@ -13,6 +13,8 @@ import WaitUntilOnPage from 'component/common/wait-until-on-page';
|
|||
import { useIsLargeScreen } from 'effects/use-screensize';
|
||||
import { GetLinksData } from 'util/buildHomepage';
|
||||
import { getLivestreamUris } from 'util/livestream';
|
||||
import ScheduledStreams from 'component/scheduledStreams';
|
||||
import { splitBySeparator } from 'util/lbryURI';
|
||||
|
||||
// @if TARGET='web'
|
||||
import Pixel from 'web/component/pixel';
|
||||
|
@ -27,6 +29,7 @@ type Props = {
|
|||
homepageData: any,
|
||||
activeLivestreams: any,
|
||||
doFetchActiveLivestreams: () => void,
|
||||
fetchingActiveLivestreams: boolean,
|
||||
};
|
||||
|
||||
function HomePage(props: Props) {
|
||||
|
@ -38,12 +41,15 @@ function HomePage(props: Props) {
|
|||
homepageData,
|
||||
activeLivestreams,
|
||||
doFetchActiveLivestreams,
|
||||
fetchingActiveLivestreams,
|
||||
} = props;
|
||||
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
|
||||
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
|
||||
const showIndividualTags = showPersonalizedTags && followedTags.length < 5;
|
||||
const isLargeScreen = useIsLargeScreen();
|
||||
|
||||
const channelIds = subscribedChannels.map((sub) => splitBySeparator(sub.uri)[1]);
|
||||
|
||||
const rowData: Array<RowDataItem> = GetLinksData(
|
||||
homepageData,
|
||||
isLargeScreen,
|
||||
|
@ -254,14 +260,28 @@ function HomePage(props: Props) {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* @if TARGET='web' */}
|
||||
{SIMPLE_SITE && <Meme />}
|
||||
<Ads type="homepage" />
|
||||
{/* @endif */}
|
||||
|
||||
{!fetchingActiveLivestreams && (
|
||||
<>
|
||||
{authenticated && channelIds.length > 0 && (
|
||||
<ScheduledStreams
|
||||
channelIds={channelIds}
|
||||
tileLayout
|
||||
liveUris={getLivestreamUris(activeLivestreams, channelIds)}
|
||||
limitClaimsPerChannel={2}
|
||||
/>
|
||||
)}
|
||||
{rowData.map(({ title, route, link, icon, help, pinnedUrls: pinUrls, options = {} }, index) => {
|
||||
// add pins here
|
||||
return getRowElements(title, route, link, icon, help, options, index, pinUrls);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* @if TARGET='web' */}
|
||||
<Pixel type={'retargeting'} />
|
||||
{/* @endif */}
|
||||
|
|
|
@ -4,16 +4,25 @@ import { doSetPlayingUri } from 'redux/actions/content';
|
|||
import { doUserSetReferrer } from 'redux/actions/user';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { DISABLE_COMMENTS_TAG } from 'constants/tags';
|
||||
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
|
||||
import { getChannelIdFromClaim } from 'util/claim';
|
||||
import { selectCurrentChannelStatus } from 'redux/selectors/livestream';
|
||||
import { doFetchActiveLivestream } from 'redux/actions/livestream';
|
||||
import LivestreamPage from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
channelClaimId: getChannelIdFromClaim(selectClaimForUri(state, props.uri)),
|
||||
chatDisabled: makeSelectTagInClaimOrChannelForUri(props.uri, DISABLE_COMMENTS_TAG)(state),
|
||||
currentChannelStatus: selectCurrentChannelStatus(state),
|
||||
});
|
||||
|
||||
export default connect(select, {
|
||||
const perform = {
|
||||
doSetPlayingUri,
|
||||
doUserSetReferrer,
|
||||
})(LivestreamPage);
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doFetchActiveLivestream,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(LivestreamPage);
|
||||
|
|
|
@ -4,7 +4,8 @@ import { lazyImport } from 'util/lazyImport';
|
|||
import Page from 'component/page';
|
||||
import LivestreamLayout from 'component/livestreamLayout';
|
||||
import analytics from 'analytics';
|
||||
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
|
||||
import moment from 'moment';
|
||||
import { LIVESTREAM_STARTS_SOON_BUFFER, LIVESTREAM_STARTED_RECENTLY_BUFFER } from 'constants/livestream';
|
||||
|
||||
const LivestreamComments = lazyImport(() => import('component/livestreamComments' /* webpackChunkName: "comments" */));
|
||||
|
||||
|
@ -16,46 +17,135 @@ type Props = {
|
|||
doUserSetReferrer: (string) => void,
|
||||
channelClaimId: ?string,
|
||||
chatDisabled: boolean,
|
||||
doCommentSocketConnect: (string, string) => void,
|
||||
doCommentSocketDisconnect: (string) => void,
|
||||
doFetchActiveLivestream: (string) => void,
|
||||
currentChannelStatus: LivestreamChannelStatus,
|
||||
};
|
||||
|
||||
export default function LivestreamPage(props: Props) {
|
||||
const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer, channelClaimId, chatDisabled } = props;
|
||||
const [isLive, setIsLive] = React.useState('pending');
|
||||
const livestreamChannelId = channelClaimId;
|
||||
const {
|
||||
uri,
|
||||
claim,
|
||||
doSetPlayingUri,
|
||||
isAuthenticated,
|
||||
doUserSetReferrer,
|
||||
channelClaimId,
|
||||
chatDisabled,
|
||||
doCommentSocketConnect,
|
||||
doCommentSocketDisconnect,
|
||||
doFetchActiveLivestream,
|
||||
currentChannelStatus,
|
||||
} = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
// TODO: This should not be needed one we unify the livestream player (?)
|
||||
analytics.playerLoadedEvent('livestream', false);
|
||||
}, []);
|
||||
|
||||
const claimId = claim && claim.claim_id;
|
||||
|
||||
// Establish web socket connection for viewer count.
|
||||
React.useEffect(() => {
|
||||
if (!livestreamChannelId) {
|
||||
setIsLive(false);
|
||||
return;
|
||||
if (claimId) {
|
||||
doCommentSocketConnect(uri, claimId);
|
||||
}
|
||||
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLive(state));
|
||||
}, [livestreamChannelId, setIsLive]);
|
||||
|
||||
return () => {
|
||||
if (claimId) {
|
||||
doCommentSocketDisconnect(claimId);
|
||||
}
|
||||
};
|
||||
}, [claimId, uri, doCommentSocketConnect, doCommentSocketDisconnect]);
|
||||
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
const [isChannelBroadcasting, setIsChannelBroadcasting] = React.useState(false);
|
||||
const [isCurrentClaimLive, setIsCurrentClaimLive] = React.useState(false);
|
||||
|
||||
const livestreamChannelId = channelClaimId || '';
|
||||
|
||||
// Find out current channels status + active live claim.
|
||||
React.useEffect(() => {
|
||||
doFetchActiveLivestream(livestreamChannelId);
|
||||
const intervalId = setInterval(() => doFetchActiveLivestream(livestreamChannelId), 30000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [livestreamChannelId, doFetchActiveLivestream]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initialized = currentChannelStatus.channelId === livestreamChannelId;
|
||||
setIsInitialized(initialized);
|
||||
if (initialized) {
|
||||
setIsChannelBroadcasting(currentChannelStatus.isBroadcasting);
|
||||
setIsCurrentClaimLive(currentChannelStatus.liveClaim.claimId === claimId);
|
||||
}
|
||||
}, [currentChannelStatus, livestreamChannelId, claimId]);
|
||||
|
||||
const [activeStreamUri, setActiveStreamUri] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveStreamUri(!isCurrentClaimLive && isChannelBroadcasting ? currentChannelStatus.liveClaim.claimUri : false);
|
||||
}, [isCurrentClaimLive, isChannelBroadcasting]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// $FlowFixMe
|
||||
const release = moment.unix(claim.value.release_time);
|
||||
|
||||
const [showLivestream, setShowLivestream] = React.useState(false);
|
||||
const [showScheduledInfo, setShowScheduledInfo] = React.useState(false);
|
||||
const [hideComments, setHideComments] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const claimReleaseInFuture = () => release.isAfter();
|
||||
|
||||
const claimReleaseInPast = () => release.isBefore();
|
||||
|
||||
const claimReleaseStartingSoon = () =>
|
||||
release.isBetween(moment(), moment().add(LIVESTREAM_STARTS_SOON_BUFFER, 'minutes'));
|
||||
|
||||
const claimReleaseStartedRecently = () =>
|
||||
release.isBetween(moment().subtract(LIVESTREAM_STARTED_RECENTLY_BUFFER, 'minutes'), moment());
|
||||
|
||||
const checkShowLivestream = () =>
|
||||
isChannelBroadcasting && isCurrentClaimLive && (claimReleaseInPast() || claimReleaseStartingSoon());
|
||||
|
||||
const checkShowScheduledInfo = () =>
|
||||
(!isChannelBroadcasting && (claimReleaseInFuture() || claimReleaseStartedRecently())) ||
|
||||
(isChannelBroadcasting &&
|
||||
((!isCurrentClaimLive && (claimReleaseInFuture() || claimReleaseStartedRecently())) ||
|
||||
(isCurrentClaimLive && claimReleaseInFuture() && !claimReleaseStartingSoon())));
|
||||
|
||||
const checkCommentsDisabled = () => chatDisabled || (claimReleaseInFuture() && !claimReleaseStartingSoon());
|
||||
|
||||
const calculateStreamReleaseState = () => {
|
||||
setShowLivestream(checkShowLivestream());
|
||||
setShowScheduledInfo(checkShowScheduledInfo());
|
||||
setHideComments(checkCommentsDisabled());
|
||||
};
|
||||
|
||||
calculateStreamReleaseState();
|
||||
const intervalId = setInterval(calculateStreamReleaseState, 1000);
|
||||
|
||||
if (isCurrentClaimLive && claimReleaseInPast() && isChannelBroadcasting === true) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [chatDisabled, isChannelBroadcasting, release, isCurrentClaimLive, isInitialized]);
|
||||
|
||||
const stringifiedClaim = JSON.stringify(claim);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (uri && stringifiedClaim) {
|
||||
const jsonClaim = JSON.parse(stringifiedClaim);
|
||||
|
||||
if (jsonClaim) {
|
||||
const { txid, nout, claim_id: claimId } = jsonClaim;
|
||||
const outpoint = `${txid}:${nout}`;
|
||||
|
||||
analytics.apiLogView(uri, outpoint, claimId);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
|
||||
if (uri) {
|
||||
doUserSetReferrer(uri.replace('lbry://', ''));
|
||||
doUserSetReferrer(uri.replace('lbry://', '')); //
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [uri, stringifiedClaim, isAuthenticated]);
|
||||
}, [uri, stringifiedClaim, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
|
||||
|
@ -64,22 +154,31 @@ export default function LivestreamPage(props: Props) {
|
|||
}, [doSetPlayingUri]);
|
||||
|
||||
return (
|
||||
isLive !== 'pending' && (
|
||||
<Page
|
||||
className="file-page"
|
||||
noFooter
|
||||
livestream
|
||||
chatDisabled={chatDisabled}
|
||||
chatDisabled={hideComments}
|
||||
rightSide={
|
||||
!chatDisabled && (
|
||||
!hideComments &&
|
||||
isInitialized && (
|
||||
<React.Suspense fallback={null}>
|
||||
<LivestreamComments uri={uri} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
>
|
||||
<LivestreamLayout uri={uri} isLive={isLive} />
|
||||
{isInitialized && (
|
||||
<LivestreamLayout
|
||||
uri={uri}
|
||||
hideComments={hideComments}
|
||||
release={release}
|
||||
isCurrentClaimLive={isCurrentClaimLive}
|
||||
showLivestream={showLivestream}
|
||||
showScheduledInfo={showScheduledInfo}
|
||||
activeStreamUri={activeStreamUri}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ type Props = {
|
|||
pendingClaims: Array<Claim>,
|
||||
doNewLivestream: (string) => void,
|
||||
fetchNoSourceClaims: (string) => void,
|
||||
myLivestreamClaims: Array<Claim>,
|
||||
myLivestreamClaims: Array<StreamClaim>,
|
||||
fetchingLivestreams: boolean,
|
||||
channelId: ?string,
|
||||
channelName: ?string,
|
||||
|
@ -67,7 +67,7 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
`Create a Livestream by first submitting your livestream details and waiting for approval confirmation. This can be done well in advance and will take a few minutes.`
|
||||
)}{' '}
|
||||
{__(
|
||||
`The livestream will not be visible on your channel page until you are live, but you can share the URL in advance.`
|
||||
`Scheduled livestreams will appear at the top of your channel page and for your followers. Regular livestreams will only appear once you are actually live.`
|
||||
)}{' '}
|
||||
{__(
|
||||
`Once the your livestream is confirmed, configure your streaming software (OBS, Restream, etc) and input the server URL along with the stream key in it.`
|
||||
|
@ -85,6 +85,7 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
<p>
|
||||
{__(`If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.`)}
|
||||
</p>
|
||||
<p>{__(`For streaming from your mobile device, we recommend PRISM Live Studio from the app store.`)}</p>
|
||||
<p>
|
||||
{__(
|
||||
`After your stream:\nClick the Update button on the content page. This will allow you to select a replay or upload your own edited MP4. Replays are limited to 4 hours and may take a few minutes to show (use the Check For Replays button).`
|
||||
|
@ -130,6 +131,42 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
};
|
||||
}, [channelId, pendingLength, fetchNoSourceClaims]);
|
||||
|
||||
const filterPending = (claims: Array<StreamClaim>) => {
|
||||
return claims.filter((claim) => {
|
||||
return !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url);
|
||||
});
|
||||
};
|
||||
|
||||
const upcomingStreams = filterPending(myLivestreamClaims).filter((claim) => {
|
||||
return Number(claim.value.release_time) * 1000 > Date.now();
|
||||
});
|
||||
|
||||
const pastStreams = filterPending(myLivestreamClaims).filter((claim) => {
|
||||
return Number(claim.value.release_time) * 1000 <= Date.now();
|
||||
});
|
||||
|
||||
type HeaderProps = {
|
||||
title: string,
|
||||
hideBtn?: boolean,
|
||||
};
|
||||
|
||||
const ListHeader = (props: HeaderProps) => {
|
||||
const { title, hideBtn = false } = props;
|
||||
return (
|
||||
<div className={'w-full flex items-center justify-between'}>
|
||||
<span>{title}</span>
|
||||
{!hideBtn && (
|
||||
<Button
|
||||
button="primary"
|
||||
iconRight={ICONS.ADD}
|
||||
onClick={() => doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`)}
|
||||
label={__('Create or Schedule a New Stream')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{fetchingChannels && (
|
||||
|
@ -150,10 +187,11 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
/>
|
||||
)}
|
||||
{!fetchingChannels && (
|
||||
<>
|
||||
<div className="section__actions--between">
|
||||
<ChannelSelector hideAnon />
|
||||
<Button button="link" onClick={() => setShowHelp(!showHelp)} label={__('How does this work?')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
|
||||
|
@ -164,14 +202,14 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
<div className="card-stack">
|
||||
{!fetchingChannels && channelId && (
|
||||
<>
|
||||
{showHelp && (
|
||||
<Card
|
||||
titleActions={<Button button="close" icon={ICONS.REMOVE} onClick={() => setShowHelp(false)} />}
|
||||
titleActions={
|
||||
<Button button="close" icon={showHelp ? ICONS.UP : ICONS.DOWN} onClick={() => setShowHelp(!showHelp)} />
|
||||
}
|
||||
title={__('Go Live on Odysee')}
|
||||
subtitle={__(`You're invited to try out our new livestreaming service while in beta!`)}
|
||||
actions={helpText}
|
||||
subtitle={<>{__(`You're invited to try out our new livestreaming service while in beta!`)} </>}
|
||||
actions={showHelp && helpText}
|
||||
/>
|
||||
)}
|
||||
{streamKey && totalLivestreamClaims.length > 0 && (
|
||||
<Card
|
||||
className="section"
|
||||
|
@ -189,7 +227,7 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
primaryButton
|
||||
enableInputMask
|
||||
name="livestream-key"
|
||||
label={__('Stream key')}
|
||||
label={__('Stream key (can be reused)')}
|
||||
copyable={streamKey}
|
||||
snackMessage={__('Copied stream key.')}
|
||||
/>
|
||||
|
@ -203,15 +241,26 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
{Boolean(pendingClaims.length) && (
|
||||
<div className="section">
|
||||
<ClaimList
|
||||
header={__('Your pending livestream uploads')}
|
||||
header={__('Your pending livestreams uploads')}
|
||||
uris={pendingClaims.map((claim) => claim.permanent_url)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(myLivestreamClaims.length) && (
|
||||
<>
|
||||
{Boolean(upcomingStreams.length) && (
|
||||
<div className="section">
|
||||
<ClaimList
|
||||
header={__('Your livestream uploads')}
|
||||
header={<ListHeader title={__('Your Scheduled Livestreams')} />}
|
||||
uris={upcomingStreams.map((claim) => claim.permanent_url)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="section">
|
||||
<ClaimList
|
||||
header={
|
||||
<ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} />
|
||||
}
|
||||
empty={
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
|
@ -227,13 +276,10 @@ export default function LivestreamSetupPage(props: Props) {
|
|||
Nothing here yet. %check_again%
|
||||
</I18nMessage>
|
||||
}
|
||||
uris={myLivestreamClaims
|
||||
.filter(
|
||||
(claim) => !pendingClaims.some((pending) => pending.permanent_url === claim.permanent_url)
|
||||
)
|
||||
.map((claim) => claim.permanent_url)}
|
||||
uris={pastStreams.map((claim) => claim.permanent_url)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -25,6 +25,7 @@ import * as COLLECTIONS_CONSTS from 'constants/collections';
|
|||
import { push } from 'connected-react-router';
|
||||
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
|
||||
import { selectBlacklistedOutpointMap } from 'lbryinc';
|
||||
import { doAnalyticsView } from 'redux/actions/app';
|
||||
import ShowPage from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
|
@ -96,6 +97,7 @@ const perform = (dispatch) => ({
|
|||
dispatch(push(`/$/${PAGES.UPLOAD}`));
|
||||
},
|
||||
fetchCollectionItems: (claimId) => dispatch(doFetchItemsInCollection({ collectionId: claimId })),
|
||||
doAnalyticsView: (uri) => dispatch(doAnalyticsView(uri)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(ShowPage));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { lazyImport } from 'util/lazyImport';
|
||||
import { Redirect, useHistory } from 'react-router-dom';
|
||||
import Spinner from 'component/spinner';
|
||||
|
@ -39,6 +39,7 @@ type Props = {
|
|||
collectionUrls: Array<string>,
|
||||
isResolvingCollection: boolean,
|
||||
fetchCollectionItems: (string) => void,
|
||||
doAnalyticsView: (string) => void,
|
||||
};
|
||||
|
||||
function ShowPage(props: Props) {
|
||||
|
@ -59,6 +60,7 @@ function ShowPage(props: Props) {
|
|||
collection,
|
||||
collectionUrls,
|
||||
isResolvingCollection,
|
||||
doAnalyticsView,
|
||||
} = props;
|
||||
|
||||
const { search } = location;
|
||||
|
@ -73,6 +75,8 @@ function ShowPage(props: Props) {
|
|||
const isCollection = claim && claim.value_type === 'collection';
|
||||
const resolvedCollection = collection && collection.id; // not null
|
||||
|
||||
const showLiveStream = isLivestream && ENABLE_NO_SOURCE_CLAIMS;
|
||||
|
||||
// changed this from 'isCollection' to resolve strangers' collections.
|
||||
React.useEffect(() => {
|
||||
if (collectionId && !resolvedCollection) {
|
||||
|
@ -113,6 +117,16 @@ function ShowPage(props: Props) {
|
|||
}
|
||||
}, [resolveUri, isResolvingUri, canonicalUrl, uri, claimExists, haventFetchedYet, isMine, claimIsPending, search]);
|
||||
|
||||
// Regular claims will call the file/view event when a user actually watches the claim
|
||||
// This can be removed when we get rid of the livestream iframe
|
||||
const [viewTracked, setViewTracked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (showLiveStream && !viewTracked) {
|
||||
doAnalyticsView(uri);
|
||||
setViewTracked(true);
|
||||
}
|
||||
}, [showLiveStream, viewTracked]);
|
||||
|
||||
// Don't navigate directly to repost urls
|
||||
// Always redirect to the actual content
|
||||
// Also need to add repost_url to the Claim type for flow
|
||||
|
@ -201,8 +215,8 @@ function ShowPage(props: Props) {
|
|||
/>
|
||||
</Page>
|
||||
);
|
||||
} else if (isLivestream && ENABLE_NO_SOURCE_CLAIMS) {
|
||||
innerContent = <LivestreamPage uri={uri} />;
|
||||
} else if (showLiveStream) {
|
||||
innerContent = <LivestreamPage uri={uri} claim={claim} />;
|
||||
} else {
|
||||
innerContent = <FilePage uri={uri} location={location} />;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// @flow
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
import { doClaimSearch } from 'redux/actions/claims';
|
||||
import { LIVESTREAM_LIVE_API } from 'constants/livestream';
|
||||
import { LIVESTREAM_LIVE_API, LIVESTREAM_STARTS_SOON_BUFFER } from 'constants/livestream';
|
||||
import moment from 'moment';
|
||||
|
||||
export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({
|
||||
|
@ -35,88 +36,179 @@ export const doFetchNoSourceClaims = (channelId: string) => async (dispatch: Dis
|
|||
|
||||
const FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const doFetchActiveLivestreams = (
|
||||
orderBy: Array<string> = ['release_time'],
|
||||
pageSize: number = 50,
|
||||
forceFetch: boolean = false
|
||||
) => {
|
||||
const transformLivestreamData = (data: Array<any>): LivestreamInfo => {
|
||||
return data.reduce((acc, curr) => {
|
||||
acc[curr.claimId] = {
|
||||
live: curr.live,
|
||||
viewCount: curr.viewCount,
|
||||
creatorId: curr.claimId,
|
||||
startedStreaming: moment(curr.timestamp),
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const fetchLiveChannels = async () => {
|
||||
const response = await fetch(LIVESTREAM_LIVE_API);
|
||||
const json = await response.json();
|
||||
if (!json.data) throw new Error();
|
||||
return transformLivestreamData(json.data);
|
||||
};
|
||||
|
||||
const fetchLiveChannel = async (channelId: string) => {
|
||||
const response = await fetch(`${LIVESTREAM_LIVE_API}/${channelId}`);
|
||||
const json = await response.json();
|
||||
if (!(json.data && json.data.live)) throw new Error();
|
||||
return transformLivestreamData([json.data]);
|
||||
};
|
||||
|
||||
const filterUpcomingLiveStreamClaims = (upcomingClaims) => {
|
||||
const startsSoonMoment = moment().startOf('minute').add(LIVESTREAM_STARTS_SOON_BUFFER, 'minutes');
|
||||
const startingSoonClaims = {};
|
||||
Object.keys(upcomingClaims).forEach((key) => {
|
||||
if (moment.unix(upcomingClaims[key].stream.value.release_time).isSameOrBefore(startsSoonMoment)) {
|
||||
startingSoonClaims[key] = upcomingClaims[key];
|
||||
}
|
||||
});
|
||||
return startingSoonClaims;
|
||||
};
|
||||
|
||||
const fetchUpcomingLivestreamClaims = (channelIds: Array<string>) => {
|
||||
return doClaimSearch({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
has_no_source: true,
|
||||
channel_ids: channelIds,
|
||||
claim_type: ['stream'],
|
||||
order_by: ['^release_time'],
|
||||
release_time: `>${moment().subtract(5, 'minutes').unix()}`,
|
||||
limit_claims_per_channel: 1,
|
||||
no_totals: true,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMostRecentLivestreamClaims = (channelIds: Array<string>, orderBy: Array<string> = ['release_time']) => {
|
||||
return doClaimSearch({
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
has_no_source: true,
|
||||
channel_ids: channelIds,
|
||||
claim_type: ['stream'],
|
||||
order_by: orderBy,
|
||||
release_time: `<${moment().unix()}`,
|
||||
limit_claims_per_channel: 2,
|
||||
no_totals: true,
|
||||
});
|
||||
};
|
||||
|
||||
const distanceFromStreamStart = (claimA: any, claimB: any, channelStartedStreaming) => {
|
||||
return [
|
||||
Math.abs(moment.unix(claimA.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
|
||||
Math.abs(moment.unix(claimB.stream.value.release_time).diff(channelStartedStreaming, 'minutes')),
|
||||
];
|
||||
};
|
||||
|
||||
const determineLiveClaim = (claims: any, activeLivestreams: any) => {
|
||||
const activeClaims = {};
|
||||
|
||||
Object.values(claims).forEach((claim: any) => {
|
||||
const channelID = claim.stream.signing_channel.claim_id;
|
||||
if (activeClaims[channelID]) {
|
||||
const [distanceA, distanceB] = distanceFromStreamStart(
|
||||
claim,
|
||||
activeClaims[channelID],
|
||||
activeLivestreams[channelID].startedStreaming
|
||||
);
|
||||
|
||||
if (distanceA < distanceB) {
|
||||
activeClaims[channelID] = claim;
|
||||
}
|
||||
} else {
|
||||
activeClaims[channelID] = claim;
|
||||
}
|
||||
});
|
||||
return activeClaims;
|
||||
};
|
||||
|
||||
const findActiveStreams = async (channelIDs: Array<string>, orderBy: Array<string>, liveChannels: any, dispatch) => {
|
||||
// @Note: This can likely be simplified down to one query, but first we'll need to address the query limit / pagination issue.
|
||||
|
||||
// Find the two most recent claims for the channels that are actively broadcasting a stream.
|
||||
const mostRecentClaims = await dispatch(fetchMostRecentLivestreamClaims(channelIDs, orderBy));
|
||||
|
||||
// Find the first upcoming claim (if one exists) for each channel that's actively broadcasting a stream.
|
||||
const upcomingClaims = await dispatch(fetchUpcomingLivestreamClaims(channelIDs));
|
||||
|
||||
// Filter out any of those claims that aren't scheduled to start within the configured "soon" buffer time (ex. next 5 min).
|
||||
const startingSoonClaims = filterUpcomingLiveStreamClaims(upcomingClaims);
|
||||
|
||||
// Reduce the claim list to one "live" claim per channel, based on how close each claim's
|
||||
// release time is to the time the channels stream started.
|
||||
const allClaims = Object.assign({}, mostRecentClaims, startingSoonClaims);
|
||||
|
||||
return determineLiveClaim(allClaims, liveChannels);
|
||||
};
|
||||
|
||||
export const doFetchActiveLivestream = (channelId: string) => {
|
||||
return async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const liveChannel = await fetchLiveChannel(channelId);
|
||||
const currentlyLiveClaims = await findActiveStreams([channelId], ['release_time'], liveChannel, dispatch);
|
||||
const liveClaim = currentlyLiveClaims[channelId];
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_COMPLETED,
|
||||
data: { liveClaim: { claimId: liveClaim.stream.claim_id, claimUri: liveClaim.stream.canonical_url } },
|
||||
});
|
||||
} catch (err) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_FAILED });
|
||||
} finally {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAM_FINISHED, data: { channelId } });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const doFetchActiveLivestreams = (orderBy: Array<string> = ['release_time']) => {
|
||||
return async (dispatch: Dispatch, getState: GetState) => {
|
||||
const state = getState();
|
||||
const now = Date.now();
|
||||
const timeDelta = now - state.livestream.activeLivestreamsLastFetchedDate;
|
||||
|
||||
const prevOptions = state.livestream.activeLivestreamsLastFetchedOptions;
|
||||
const nextOptions = { page_size: pageSize, order_by: orderBy };
|
||||
const nextOptions = { order_by: orderBy };
|
||||
const sameOptions = JSON.stringify(prevOptions) === JSON.stringify(nextOptions);
|
||||
|
||||
if (!forceFetch && sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
|
||||
if (sameOptions && timeDelta < FETCH_ACTIVE_LIVESTREAMS_MIN_INTERVAL_MS) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_SKIPPED });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_STARTED });
|
||||
|
||||
fetch(LIVESTREAM_LIVE_API)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.data) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const liveChannels = await fetchLiveChannels();
|
||||
const liveChannelIds = Object.keys(liveChannels);
|
||||
|
||||
const activeLivestreams: LivestreamInfo = res.data.reduce((acc, curr) => {
|
||||
acc[curr.claimId] = {
|
||||
live: curr.live,
|
||||
viewCount: curr.viewCount,
|
||||
creatorId: curr.claimId,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
const currentlyLiveClaims = await findActiveStreams(liveChannelIds, nextOptions.order_by, liveChannels, dispatch);
|
||||
Object.values(currentlyLiveClaims).forEach((claim: any) => {
|
||||
const channelId = claim.stream.signing_channel.claim_id;
|
||||
|
||||
dispatch(
|
||||
// ** Creators can have multiple livestream claims (each with unique
|
||||
// chat), and all of them will play the same stream when creator goes
|
||||
// live. The UI usually just wants to report the latest claim, so we
|
||||
// query that store it in `latestClaimUri`.
|
||||
doClaimSearch({
|
||||
page: 1,
|
||||
page_size: nextOptions.page_size,
|
||||
has_no_source: true,
|
||||
channel_ids: Object.keys(activeLivestreams),
|
||||
claim_type: ['stream'],
|
||||
order_by: nextOptions.order_by, // **
|
||||
limit_claims_per_channel: 1, // **
|
||||
no_totals: true,
|
||||
})
|
||||
)
|
||||
.then((resolveInfo) => {
|
||||
Object.values(resolveInfo).forEach((x) => {
|
||||
// $FlowFixMe
|
||||
const channelId = x.stream.signing_channel.claim_id;
|
||||
activeLivestreams[channelId] = {
|
||||
...activeLivestreams[channelId],
|
||||
// $FlowFixMe
|
||||
latestClaimId: x.stream.claim_id,
|
||||
// $FlowFixMe
|
||||
latestClaimUri: x.stream.canonical_url,
|
||||
liveChannels[channelId] = {
|
||||
...liveChannels[channelId],
|
||||
claimId: claim.stream.claim_id,
|
||||
claimUri: claim.stream.canonical_url,
|
||||
};
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_COMPLETED,
|
||||
data: {
|
||||
activeLivestreams,
|
||||
activeLivestreams: liveChannels,
|
||||
activeLivestreamsLastFetchedDate: now,
|
||||
activeLivestreamsLastFetchedOptions: nextOptions,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (err) {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch({ type: ACTIONS.FETCH_ACTIVE_LIVESTREAMS_FAILED });
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,14 +1,28 @@
|
|||
// @flow
|
||||
|
||||
import * as ACTIONS from 'constants/action_types';
|
||||
import { handleActions } from 'util/redux-utils';
|
||||
|
||||
const currentChannelStatus: LivestreamChannelStatus = {
|
||||
channelId: null,
|
||||
isBroadcasting: false,
|
||||
liveClaim: {
|
||||
claimId: null,
|
||||
claimUri: null,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultState: LivestreamState = {
|
||||
fetchingById: {},
|
||||
viewersById: {},
|
||||
fetchingActiveLivestreams: false,
|
||||
fetchingActiveLivestreams: 'pending',
|
||||
activeLivestreams: null,
|
||||
activeLivestreamsLastFetchedDate: 0,
|
||||
activeLivestreamsLastFetchedOptions: {},
|
||||
|
||||
currentChannelStatus: {
|
||||
...currentChannelStatus,
|
||||
},
|
||||
};
|
||||
|
||||
export default handleActions(
|
||||
|
@ -56,6 +70,25 @@ export default handleActions(
|
|||
activeLivestreamsLastFetchedOptions,
|
||||
};
|
||||
},
|
||||
|
||||
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_COMPLETED]: (state: LivestreamState, action: any) => {
|
||||
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, {
|
||||
isBroadcasting: true,
|
||||
liveClaim: action.data.liveClaim,
|
||||
});
|
||||
return { ...state, currentChannelStatus };
|
||||
},
|
||||
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_FAILED]: (state: LivestreamState) => {
|
||||
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, {
|
||||
isBroadcasting: false,
|
||||
liveClaim: { claimId: null, claimUri: null },
|
||||
});
|
||||
return { ...state, currentChannelStatus };
|
||||
},
|
||||
[ACTIONS.FETCH_ACTIVE_LIVESTREAM_FINISHED]: (state: LivestreamState, action: any) => {
|
||||
const currentChannelStatus = Object.assign({}, state.currentChannelStatus, { channelId: action.data.channelId });
|
||||
return { ...state, currentChannelStatus };
|
||||
},
|
||||
},
|
||||
defaultState
|
||||
);
|
||||
|
|
|
@ -57,7 +57,11 @@ export const selectIsActiveLivestreamForUri = createCachedSelector(
|
|||
}
|
||||
|
||||
const activeLivestreamValues = Object.values(activeLivestreams);
|
||||
// $FlowFixMe - unable to resolve latestClaimUri
|
||||
return activeLivestreamValues.some((v) => v.latestClaimUri === uri);
|
||||
// $FlowFixMe - unable to resolve claimUri
|
||||
return activeLivestreamValues.some((v) => v.claimUri === uri);
|
||||
}
|
||||
)((state, uri) => String(uri));
|
||||
|
||||
export const selectFetchingActiveLivestreams = (state: State) => selectState(state).fetchingActiveLivestreams;
|
||||
|
||||
export const selectCurrentChannelStatus = (state: State) => selectState(state).currentChannelStatus;
|
||||
|
|
|
@ -85,6 +85,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.file-render--scheduledLivestream {
|
||||
background-image: url('//spee.ch/odysee-streaming-png:2.png?quality=80&height=960&width=1920');
|
||||
background-size: cover;
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
@keyframes fadeInFromBlack {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
|
|
@ -451,6 +451,11 @@ fieldset-group {
|
|||
font-size: var(--font-xsmall);
|
||||
}
|
||||
|
||||
.form-field__hint {
|
||||
font-size: var(--font-xsmall);
|
||||
color: var(--color-input-label);
|
||||
}
|
||||
|
||||
.form-field__textarea-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -506,6 +511,7 @@ fieldset-section {
|
|||
|
||||
.form-field-date-picker {
|
||||
margin-bottom: var(--spacing-l);
|
||||
font-size: var(--font-base);
|
||||
|
||||
label {
|
||||
display: block;
|
||||
|
|
|
@ -1,3 +1,78 @@
|
|||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.opacity-40 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.mt-s {
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.mt-m {
|
||||
margin-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
.mb-m {
|
||||
margin-bottom: var(--spacing-m);
|
||||
}
|
||||
|
||||
.mb-xl {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.ml-m {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
|
||||
.mr-m {
|
||||
margin-right: var(--spacing-m);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-small) {
|
||||
.md\:items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.md\:flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.md\:ml-m {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
.md\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.md\:w-auto {
|
||||
width: auto;
|
||||
}
|
||||
.md\:mt-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.md\:h-12 {
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
|
19
ui/scss/component/livestream-scheduled-info.scss
Normal file
19
ui/scss/component/livestream-scheduled-info.scss
Normal 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);
|
||||
}
|
|
@ -13,12 +13,12 @@ export function getLivestreamUris(activeLivestreams: ?LivestreamInfo, channelIds
|
|||
|
||||
if (channelIds && channelIds.length > 0) {
|
||||
// $FlowFixMe
|
||||
values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.latestClaimUri));
|
||||
values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.claimUri));
|
||||
} else {
|
||||
// $FlowFixMe
|
||||
values = values.filter((v) => Boolean(v.latestClaimUri));
|
||||
values = values.filter((v) => Boolean(v.claimUri));
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
return values.map((v) => v.latestClaimUri);
|
||||
return values.map((v) => v.claimUri);
|
||||
}
|
||||
|
|
47
yarn.lock
47
yarn.lock
|
@ -11215,6 +11215,11 @@ merge-descriptors@1.0.1:
|
|||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
|
||||
|
||||
merge-refs@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-refs/-/merge-refs-1.0.0.tgz#388348bce22e623782c6df9d3c4fc55888276120"
|
||||
integrity sha512-WZ4S5wqD9FCR9hxkLgvcHJCBxzXzy3VVE6p8W2OzxRzB+hLRlcadGE2bW9xp2KSzk10rvp4y+pwwKO6JQVguMg==
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
|
@ -13657,25 +13662,26 @@ react-confetti@^4.0.1:
|
|||
dependencies:
|
||||
tween-functions "^1.2.0"
|
||||
|
||||
react-date-picker@^8.1.0:
|
||||
version "8.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.1.1.tgz#1959608cd042c9bfcf2faa6d63a56e9ef6b17e2b"
|
||||
integrity sha512-kFhn+uSJML+EuROvR6qLYU5G3wsxrdB2K1ugh1t6HjJCjphE6ot85jb8THWebqWEcQi07pLseU7ZFpzKDD3A6A==
|
||||
react-date-picker@^8.3.3:
|
||||
version "8.3.6"
|
||||
resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.6.tgz#446142bee5691aea66a2bac53313357aca561cd4"
|
||||
integrity sha512-c1rThf0jSKROoSGLpUEPtcC8VE+XoVgqxh+ng9aLYQvjDMGWQBgoat6Qrj8nRVzvCPpdXV4jqiCB3z2vVVuseA==
|
||||
dependencies:
|
||||
"@types/react-calendar" "^3.0.0"
|
||||
"@wojtekmaj/date-utils" "^1.0.3"
|
||||
get-user-locale "^1.2.0"
|
||||
make-event-props "^1.1.0"
|
||||
merge-class-names "^1.1.1"
|
||||
merge-refs "^1.0.0"
|
||||
prop-types "^15.6.0"
|
||||
react-calendar "^3.3.1"
|
||||
react-fit "^1.0.3"
|
||||
update-input-width "^1.1.1"
|
||||
update-input-width "^1.2.2"
|
||||
|
||||
react-datetime-picker@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.2.1.tgz#d3a9631bcba17bd0047e6424cff0dfe242d9cf0e"
|
||||
integrity sha512-elybaAL7RJG7r0elYZze5/zQo1ds0v+v89tyZkzEShw+6I1EcveXwYPOMj3aq0k7D5kY/K+dC5dWYw0w4d9kmw==
|
||||
react-datetime-picker@^3.4.3:
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/react-datetime-picker/-/react-datetime-picker-3.4.3.tgz#9163471f72b708185482b6b72cd259da03462f79"
|
||||
integrity sha512-yuFmh3TJwDo3VnyQF6auRJoeYfFTUtyLsR292lWXieigp0ugKkQefUEzVybZQidiiUlCNK9UQgc37/igl7uBYA==
|
||||
dependencies:
|
||||
"@wojtekmaj/date-utils" "^1.0.3"
|
||||
get-user-locale "^1.2.0"
|
||||
|
@ -13684,9 +13690,9 @@ react-datetime-picker@^3.2.1:
|
|||
prop-types "^15.6.0"
|
||||
react-calendar "^3.3.1"
|
||||
react-clock "^3.0.0"
|
||||
react-date-picker "^8.1.0"
|
||||
react-date-picker "^8.3.3"
|
||||
react-fit "^1.0.3"
|
||||
react-time-picker "^4.2.0"
|
||||
react-time-picker "^4.4.2"
|
||||
|
||||
react-dev-utils@^3.0.2, react-dev-utils@^3.1.0:
|
||||
version "3.1.3"
|
||||
|
@ -13914,19 +13920,20 @@ react-spring@^8.0.20, react-spring@^8.0.27:
|
|||
"@babel/runtime" "^7.3.1"
|
||||
prop-types "^15.5.8"
|
||||
|
||||
react-time-picker@^4.2.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.2.1.tgz#b27f0bbc2e58534f20dbf10b14d0b8f3334fcb07"
|
||||
integrity sha512-T0aEabJ3bz54l8LV3pdpB5lOZuO3pRIbry5STcUV58UndlrWLcHpdpvS1IC8JLNXhbLxzGs1MmpASb5k1ddlsg==
|
||||
react-time-picker@^4.4.2:
|
||||
version "4.4.4"
|
||||
resolved "https://registry.yarnpkg.com/react-time-picker/-/react-time-picker-4.4.4.tgz#a67ca5fd88f51eac0919df802e416d9a25ad726a"
|
||||
integrity sha512-WMdrpGnegug0871Do+SU1Fe91uZGmS6JUo1Yw7eLfU3VHMXCFj9sL9FAT6BuXe7lfILBbXq4tQQOqa/rLDASQg==
|
||||
dependencies:
|
||||
"@wojtekmaj/date-utils" "^1.0.0"
|
||||
get-user-locale "^1.2.0"
|
||||
make-event-props "^1.1.0"
|
||||
merge-class-names "^1.1.1"
|
||||
merge-refs "^1.0.0"
|
||||
prop-types "^15.6.0"
|
||||
react-clock "^3.0.0"
|
||||
react-fit "^1.0.3"
|
||||
update-input-width "^1.1.1"
|
||||
update-input-width "^1.2.2"
|
||||
|
||||
react-top-loading-bar@^2.0.1:
|
||||
version "2.0.1"
|
||||
|
@ -16705,10 +16712,10 @@ upath@^1.1.1:
|
|||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
|
||||
|
||||
update-input-width@^1.1.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.1.tgz#769d6182413590c3b50b52ffa9c65d79e2c17f95"
|
||||
integrity sha512-zygDshqDb2C2/kgfoD423n5htv/3OBF7aTaz2u2zZy998EJki8njOHOeZjKEd8XSYeDziIX1JXfMsKaIRJeJ/Q==
|
||||
update-input-width@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/update-input-width/-/update-input-width-1.2.2.tgz#9a6a35858ae8e66fbfe0304437b23a4934fc7d37"
|
||||
integrity sha512-6QwD9ZVSXb96PxOZ01DU0DJTPwQGY7qBYgdniZKJN02Xzom2m+9J6EPxMbefskqtj4x78qbe5psDSALq9iNEYg==
|
||||
|
||||
update-notifier@^2.3.0, update-notifier@^2.5.0:
|
||||
version "2.5.0"
|
||||
|
|
Loading…
Reference in a new issue