Fix livestream state issues. Create unified long polling mechanism.
This commit is contained in:
parent
a7c7881795
commit
60f06dac52
9 changed files with 168 additions and 128 deletions
|
@ -22,21 +22,23 @@ import { doResolveUri } from 'redux/actions/claims';
|
|||
import { doCollectionEdit } from 'redux/actions/collections';
|
||||
import { doFileGet } from 'redux/actions/file';
|
||||
import { selectBanStateForUri } from 'lbryinc';
|
||||
import { makeSelectIsActiveLivestream } from 'redux/selectors/livestream';
|
||||
import { selectShowMatureContent } from 'redux/selectors/settings';
|
||||
import { makeSelectHasVisitedUri } from 'redux/selectors/content';
|
||||
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
|
||||
import ClaimPreview from './view';
|
||||
import formatMediaDuration from 'util/formatMediaDuration';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
|
||||
const select = (state, props) => {
|
||||
const claim = props.uri && selectClaimForUri(state, props.uri);
|
||||
const { claim_id: channelId } = selectActiveChannelClaim(state) || {};
|
||||
const media = claim && claim.value && (claim.value.video || claim.value.audio);
|
||||
const mediaDuration = media && media.duration && formatMediaDuration(media.duration, { screenReader: true });
|
||||
|
||||
return {
|
||||
claim,
|
||||
mediaDuration,
|
||||
channelId,
|
||||
date: props.uri && selectDateForUri(state, props.uri),
|
||||
title: props.uri && makeSelectTitleForUri(props.uri)(state),
|
||||
pending: props.uri && makeSelectClaimIsPending(props.uri)(state),
|
||||
|
@ -52,7 +54,6 @@ const select = (state, props) => {
|
|||
streamingUrl: props.uri && makeSelectStreamingUrlForUri(props.uri)(state),
|
||||
wasPurchased: props.uri && makeSelectClaimWasPurchased(props.uri)(state),
|
||||
isLivestream: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
|
||||
isLivestreamActive: makeSelectIsActiveLivestream(props.uri)(state),
|
||||
isCollectionMine: makeSelectCollectionIsMine(props.collectionId)(state),
|
||||
collectionUris: makeSelectUrlsForCollectionId(props.collectionId)(state),
|
||||
collectionIndex: makeSelectIndexForUrlInCollection(props.uri, props.collectionId)(state),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import type { Node } from 'react';
|
||||
import React, { useEffect, forwardRef } from 'react';
|
||||
import React, { useEffect, forwardRef, useState } from 'react';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
import { isEmpty } from 'util/object';
|
||||
import { lazyImport } from 'util/lazyImport';
|
||||
|
@ -31,6 +31,7 @@ import ClaimPreviewNoContent from './claim-preview-no-content';
|
|||
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import Button from 'component/button';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
|
||||
|
||||
const AbandonedChannelPreview = lazyImport(() =>
|
||||
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
|
||||
|
@ -40,6 +41,7 @@ const AbandonedChannelPreview = lazyImport(() =>
|
|||
type Props = {
|
||||
uri: string,
|
||||
claim: ?Claim,
|
||||
channelId: string,
|
||||
active: boolean,
|
||||
obscureNsfw: boolean,
|
||||
showUserBlocked: boolean,
|
||||
|
@ -74,7 +76,6 @@ type Props = {
|
|||
repostUrl?: string,
|
||||
hideMenu?: boolean,
|
||||
isLivestream?: boolean,
|
||||
isLivestreamActive: boolean,
|
||||
collectionId?: string,
|
||||
editCollection: (string, CollectionEditParams) => void,
|
||||
isCollectionMine: boolean,
|
||||
|
@ -92,6 +93,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
// core
|
||||
uri,
|
||||
claim,
|
||||
channelId,
|
||||
isResolvingUri,
|
||||
// core actions
|
||||
getFile,
|
||||
|
@ -135,8 +137,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
renderActions,
|
||||
hideMenu = false,
|
||||
// repostUrl,
|
||||
isLivestream, // need both? CHECK
|
||||
isLivestreamActive,
|
||||
isLivestream,
|
||||
collectionId,
|
||||
collectionIndex,
|
||||
editCollection,
|
||||
|
@ -146,6 +147,14 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
indexInContainer,
|
||||
channelSubCount,
|
||||
} = props;
|
||||
|
||||
const [isLivestreamActive, setIsLivestreamActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLivestream) return;
|
||||
return watchLivestreamStatus(channelId, (state) => setIsLivestreamActive(state));
|
||||
}, [channelId, setIsLivestreamActive, isLivestream]);
|
||||
|
||||
const isCollection = claim && claim.value_type === 'collection';
|
||||
const collectionClaimId = isCollection && claim && claim.claim_id;
|
||||
const listId = collectionId || collectionClaimId;
|
||||
|
@ -482,11 +491,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{claimIsMine && isLivestream && (
|
||||
<div className={'claim-preview__hints'}>
|
||||
<ClaimPreviewReset />
|
||||
</div>
|
||||
)}
|
||||
{claim && isLivestream && isLivestreamActive && <ClaimPreviewReset uri={uri} />}
|
||||
|
||||
{!hideMenu && <ClaimMenuList uri={uri} collectionId={listId} />}
|
||||
</>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
import { makeSelectClaimIsMine } from 'redux/selectors/claims';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import ClaimPreviewReset from './view';
|
||||
|
||||
const select = (state) => {
|
||||
const select = (state, props) => {
|
||||
const { claim_id: channelId, name: channelName } = selectActiveChannelClaim(state) || {};
|
||||
return {
|
||||
channelName,
|
||||
channelId,
|
||||
claimIsMine: props.uri && makeSelectClaimIsMine(props.uri)(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,65 +1,26 @@
|
|||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
import Lbry from 'lbry';
|
||||
import { LIVESTREAM_KILL } from 'constants/livestream';
|
||||
import { SITE_HELP_EMAIL } from 'config';
|
||||
import { toHex } from 'util/hex';
|
||||
import Button from 'component/button';
|
||||
import { killStream } from '$web/src/livestreaming';
|
||||
import 'scss/component/claim-preview-reset.scss';
|
||||
|
||||
// @Todo: move out of component.
|
||||
const getStreamData = async (channelId: string, channelName: string) => {
|
||||
if (!channelId || !channelName) throw new Error('Invalid channel data provided.');
|
||||
|
||||
const channelNameHex = toHex(channelName);
|
||||
let channelSignature;
|
||||
|
||||
try {
|
||||
channelSignature = await Lbry.channel_sign({ channel_id: channelId, hexdata: channelNameHex });
|
||||
if (!channelSignature || !channelSignature.signature || !channelSignature.signing_ts) {
|
||||
throw new Error('Error getting channel signature.');
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
d: channelNameHex,
|
||||
s: channelSignature.signature,
|
||||
t: channelSignature.signing_ts,
|
||||
};
|
||||
};
|
||||
|
||||
// @Todo: move out of component.
|
||||
const killStream = async (channelId: string, payload: any) => {
|
||||
fetch(`${LIVESTREAM_KILL}/${channelId}`, {
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(payload),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.status === 200) throw new Error('Kill stream API failed.');
|
||||
})
|
||||
.catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
type Props = {
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
claimIsMine: boolean,
|
||||
doToast: ({ message: string, isError?: boolean }) => void,
|
||||
};
|
||||
|
||||
const ClaimPreviewReset = (props: Props) => {
|
||||
const { channelId, channelName, doToast } = props;
|
||||
const { channelId, channelName, claimIsMine, doToast } = props;
|
||||
|
||||
if (!claimIsMine) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
const streamData = await getStreamData(channelId, channelName);
|
||||
await killStream(channelId, streamData);
|
||||
await killStream(channelId, channelName);
|
||||
doToast({ message: __('Live stream successfully reset.'), isError: false });
|
||||
} catch {
|
||||
doToast({ message: __('There was an error resetting the live stream.'), isError: true });
|
||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import DateTime from 'component/dateTime';
|
||||
import FileViewCount from 'component/fileViewCount';
|
||||
import FileActions from 'component/fileActions';
|
||||
import ClaimPreviewReset from 'component/claimPreviewReset';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
|
@ -15,6 +16,7 @@ function FileSubtitle(props: Props) {
|
|||
const { uri, livestream = false, activeViewers, isLive = false } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="media__subtitle--between">
|
||||
<div className="file__viewdate">
|
||||
{livestream ? <span>{__('Right now')}</span> : <DateTime uri={uri} show={DateTime.SHOW_DATE} />}
|
||||
|
@ -24,6 +26,8 @@ function FileSubtitle(props: Props) {
|
|||
|
||||
<FileActions uri={uri} hideRepost={livestream} livestream={livestream} />
|
||||
</div>
|
||||
{livestream && isLive && <ClaimPreviewReset uri={uri} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
// @flow
|
||||
import { LIVESTREAM_LIVE_API } from 'constants/livestream';
|
||||
import * as CS from 'constants/claim_search';
|
||||
import React from 'react';
|
||||
import Card from 'component/common/card';
|
||||
|
@ -7,6 +6,7 @@ import ClaimPreview from 'component/claimPreview';
|
|||
import Lbry from 'lbry';
|
||||
import { useHistory } from 'react-router';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
|
||||
|
||||
type Props = {
|
||||
channelClaim: ChannelClaim,
|
||||
|
@ -47,38 +47,9 @@ export default function LivestreamLink(props: Props) {
|
|||
}, [livestreamChannelId, isChannelEmpty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function fetchIsStreaming() {
|
||||
// $FlowFixMe livestream API can handle garbage
|
||||
fetch(`${LIVESTREAM_LIVE_API}/${livestreamChannelId}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res && res.success && res.data && res.data.live) {
|
||||
setIsLivestreaming(true);
|
||||
} else {
|
||||
setIsLivestreaming(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {});
|
||||
}
|
||||
|
||||
let interval;
|
||||
// Only call livestream api if channel has livestream claims
|
||||
if (livestreamChannelId && livestreamClaim) {
|
||||
if (!interval) fetchIsStreaming();
|
||||
interval = setInterval(fetchIsStreaming, 10 * 1000);
|
||||
}
|
||||
// Prevent any more api calls on update
|
||||
if (!livestreamChannelId || !livestreamClaim) {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [livestreamChannelId, livestreamClaim]);
|
||||
if (!livestreamClaim) return;
|
||||
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLivestreaming(state));
|
||||
}, [livestreamChannelId, setIsLivestreaming, livestreamClaim]);
|
||||
|
||||
if (!livestreamClaim || !isLivestreaming) {
|
||||
return null;
|
||||
|
@ -87,7 +58,7 @@ export default function LivestreamLink(props: Props) {
|
|||
// gonna pass the wrapper in so I don't have to rewrite the dmca/blocking logic in claimPreview.
|
||||
const element = (props: { children: any }) => (
|
||||
<Card
|
||||
className="livestream__channel-link"
|
||||
className="livestream__channel-link claim-preview__live"
|
||||
title={__('Live stream in progress')}
|
||||
onClick={() => {
|
||||
push(formatLbryUrlForWeb(livestreamClaim.canonical_url));
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
// @flow
|
||||
import { LIVESTREAM_LIVE_API } from 'constants/livestream';
|
||||
import React from 'react';
|
||||
import Page from 'component/page';
|
||||
import LivestreamLayout from 'component/livestreamLayout';
|
||||
import LivestreamComments from 'component/livestreamComments';
|
||||
import analytics from 'analytics';
|
||||
import Lbry from 'lbry';
|
||||
import watchLivestreamStatus from '$web/src/livestreaming/long-polling';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
|
@ -23,7 +23,6 @@ export default function LivestreamPage(props: Props) {
|
|||
const livestreamChannelId = channelClaim && channelClaim.signing_channel && channelClaim.signing_channel.claim_id;
|
||||
const [hasLivestreamClaim, setHasLivestreamClaim] = React.useState(false);
|
||||
|
||||
const STREAMING_POLL_INTERVAL_IN_MS = 10000;
|
||||
const LIVESTREAM_CLAIM_POLL_IN_MS = 60000;
|
||||
|
||||
React.useEffect(() => {
|
||||
|
@ -54,34 +53,9 @@ export default function LivestreamPage(props: Props) {
|
|||
}, [livestreamChannelId, isLive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let interval;
|
||||
function checkIsLive() {
|
||||
// TODO: duplicate code below
|
||||
// $FlowFixMe livestream API can handle garbage
|
||||
fetch(`${LIVESTREAM_LIVE_API}/${livestreamChannelId}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res || !res.data) {
|
||||
setIsLive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data.hasOwnProperty('live')) {
|
||||
setIsLive(res.data.live);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (livestreamChannelId && hasLivestreamClaim) {
|
||||
if (!interval) checkIsLive();
|
||||
interval = setInterval(checkIsLive, STREAMING_POLL_INTERVAL_IN_MS);
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [livestreamChannelId, hasLivestreamClaim]);
|
||||
if (!hasLivestreamClaim || !livestreamChannelId) return;
|
||||
return watchLivestreamStatus(livestreamChannelId, (state) => setIsLive(state));
|
||||
}, [livestreamChannelId, setIsLive, hasLivestreamClaim]);
|
||||
|
||||
const stringifiedClaim = JSON.stringify(claim);
|
||||
React.useEffect(() => {
|
||||
|
|
55
web/src/livestreaming/index.js
Normal file
55
web/src/livestreaming/index.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
// @flow
|
||||
|
||||
import Lbry from 'lbry';
|
||||
import { LIVESTREAM_KILL, LIVESTREAM_LIVE_API } from 'constants/livestream';
|
||||
import { toHex } from 'util/hex';
|
||||
|
||||
type StreamData = {
|
||||
d: string,
|
||||
s: string,
|
||||
t: string,
|
||||
};
|
||||
|
||||
export const getStreamData = async (channelId: string, channelName: string): Promise<StreamData> => {
|
||||
if (!channelId || !channelName) throw new Error('Invalid channel data provided.');
|
||||
|
||||
const channelNameHex = toHex(channelName);
|
||||
let channelSignature;
|
||||
|
||||
try {
|
||||
channelSignature = await Lbry.channel_sign({ channel_id: channelId, hexdata: channelNameHex });
|
||||
if (!channelSignature || !channelSignature.signature || !channelSignature.signing_ts) {
|
||||
throw new Error('Error getting channel signature.');
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { d: channelNameHex, s: channelSignature.signature, t: channelSignature.signing_ts };
|
||||
};
|
||||
|
||||
export const killStream = async (channelId: string, channelName: string) => {
|
||||
try {
|
||||
const streamData = await getStreamData(channelId, channelName);
|
||||
fetch(`${LIVESTREAM_KILL}/${channelId}`, {
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(streamData),
|
||||
}).then((res) => {
|
||||
if (res.status !== 200) throw new Error('Kill stream API failed.');
|
||||
});
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const isLiveStreaming = async (channelId: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${LIVESTREAM_LIVE_API}/${channelId}`);
|
||||
const stream = await response.json();
|
||||
return stream.data?.live;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
67
web/src/livestreaming/long-polling.js
Normal file
67
web/src/livestreaming/long-polling.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
// @flow
|
||||
|
||||
/*
|
||||
* This module is responsible for long polling the server to determine if a channel is actively streaming.
|
||||
*
|
||||
* One or many entities can subscribe to the live status while instantiating just one long poll interval per channel.
|
||||
* Once all interested parties have disconnected the poll will shut down. For this reason, be sure to always call the
|
||||
* disconnect function returned upon connecting.
|
||||
*/
|
||||
|
||||
import { isLiveStreaming } from '$web/src/livestreaming';
|
||||
|
||||
const POLL_INTERVAL = 10000;
|
||||
const pollers = {};
|
||||
|
||||
const pollingMechanism = {
|
||||
streaming: false,
|
||||
|
||||
startPolling() {
|
||||
if (this.interval !== 0) return;
|
||||
const poll = async () => {
|
||||
this.streaming = await isLiveStreaming(this.channelId);
|
||||
this.subscribers.forEach((cb) => {
|
||||
if (cb) cb(this.streaming);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
this.interval = setInterval(poll, POLL_INTERVAL);
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
clearInterval(this.interval);
|
||||
this.interval = 0;
|
||||
},
|
||||
|
||||
connect(cb): number {
|
||||
cb(this.streaming);
|
||||
this.startPolling();
|
||||
return this.subscribers.push(cb) - 1;
|
||||
},
|
||||
|
||||
disconnect(subscriberIndex: number) {
|
||||
this.subscribers[subscriberIndex] = null;
|
||||
if (this.subscribers.every((item) => item === null)) {
|
||||
this.stopPolling();
|
||||
delete pollers[this.channelId];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const generateLongPoll = (channelId: string) => {
|
||||
if (pollers[channelId]) return pollers[channelId];
|
||||
|
||||
pollers[channelId] = Object.create({
|
||||
channelId,
|
||||
interval: 0,
|
||||
subscribers: [],
|
||||
...pollingMechanism,
|
||||
});
|
||||
return pollers[channelId];
|
||||
};
|
||||
|
||||
export default (channelId: string, cb: (boolean) => void) => {
|
||||
const poll = generateLongPoll(channelId);
|
||||
const subscriberIndex = poll.connect(cb);
|
||||
return () => poll.disconnect(subscriberIndex);
|
||||
};
|
Loading…
Reference in a new issue