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