-
-
+ {livestream ? {__('Right now')} : }
+ {livestream ? (
+ {__('%viewer_count% currently watching', { viewer_count: activeViewers })}
+ ) : (
+
+ )}
diff --git a/ui/component/fileTitleSection/view.jsx b/ui/component/fileTitleSection/view.jsx
index 1d2ffe1a8..57d679fe4 100644
--- a/ui/component/fileTitleSection/view.jsx
+++ b/ui/component/fileTitleSection/view.jsx
@@ -18,10 +18,12 @@ type Props = {
title: string,
nsfw: boolean,
isNsfwBlocked: boolean,
+ livestream?: boolean,
+ activeViewers?: number,
};
function FileTitleSection(props: Props) {
- const { title, uri, nsfw, isNsfwBlocked } = props;
+ const { title, uri, nsfw, isNsfwBlocked, livestream = false, activeViewers } = props;
return (
-
+
}
actions={
diff --git a/ui/component/fileType/index.js b/ui/component/fileType/index.js
index 51f4c3e7f..2b5269d9e 100644
--- a/ui/component/fileType/index.js
+++ b/ui/component/fileType/index.js
@@ -1,9 +1,10 @@
import { connect } from 'react-redux';
-import { makeSelectMediaTypeForUri } from 'lbry-redux';
+import { makeSelectMediaTypeForUri, makeSelectClaimHasSource } from 'lbry-redux';
import FileType from './view';
const select = (state, props) => ({
mediaType: makeSelectMediaTypeForUri(props.uri)(state),
+ isLivestream: !makeSelectClaimHasSource(props.uri)(state),
});
export default connect(select)(FileType);
diff --git a/ui/component/fileType/view.jsx b/ui/component/fileType/view.jsx
index d1a72d9a0..d82083c8b 100644
--- a/ui/component/fileType/view.jsx
+++ b/ui/component/fileType/view.jsx
@@ -6,16 +6,17 @@ import Icon from 'component/common/icon';
type Props = {
uri: string,
mediaType: string,
+ isLivestream: boolean,
};
function FileType(props: Props) {
- const { mediaType } = props;
+ const { mediaType, isLivestream } = props;
if (mediaType === 'image') {
return ;
} else if (mediaType === 'audio') {
return ;
- } else if (mediaType === 'video') {
+ } else if (mediaType === 'video' || isLivestream) {
return ;
} else if (mediaType === 'text') {
return ;
diff --git a/ui/component/header/view.jsx b/ui/component/header/view.jsx
index e98fc7077..0af8f65f4 100644
--- a/ui/component/header/view.jsx
+++ b/ui/component/header/view.jsx
@@ -1,4 +1,5 @@
// @flow
+import { LOGO_TITLE, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import * as ICONS from 'constants/icons';
import { SETTINGS } from 'lbry-redux';
import * as PAGES from 'constants/pages';
@@ -10,7 +11,6 @@ import WunderBar from 'component/wunderbar';
import Icon from 'component/common/icon';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import NavigationButton from 'component/navigationButton';
-import { LOGO_TITLE } from 'config';
import { useIsMobile } from 'effects/use-screensize';
import NotificationBubble from 'component/notificationBubble';
import NotificationHeaderButton from 'component/notificationHeaderButton';
@@ -99,6 +99,7 @@ const Header = (props: Props) => {
const hasBackout = Boolean(backout);
const { backLabel, backNavDefault, title: backTitle, simpleTitle: simpleBackTitle } = backout || {};
const notificationsEnabled = (user && user.experimental_ui) || false;
+ const livestreamEnabled = (ENABLE_NO_SOURCE_CLAIMS && user && user.experimental_ui) || false;
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
// Sign out if they click the "x" when they are on the password prompt
@@ -275,6 +276,7 @@ const Header = (props: Props) => {
history={history}
handleThemeToggle={handleThemeToggle}
currentTheme={currentTheme}
+ livestreamEnabled={livestreamEnabled}
/>
)}
@@ -391,10 +393,11 @@ type HeaderMenuButtonProps = {
history: { push: (string) => void },
handleThemeToggle: (string) => void,
currentTheme: string,
+ livestreamEnabled: boolean,
};
function HeaderMenuButtons(props: HeaderMenuButtonProps) {
- const { authenticated, notificationsEnabled, history, handleThemeToggle, currentTheme } = props;
+ const { authenticated, notificationsEnabled, history, handleThemeToggle, currentTheme, livestreamEnabled } = props;
return (
@@ -422,6 +425,13 @@ function HeaderMenuButtons(props: HeaderMenuButtonProps) {
{__('New Channel')}
+
+ {livestreamEnabled && (
+
history.push(`/$/${PAGES.LIVESTREAM}`)}>
+
+ {__('Go Live')}
+
+ )}
)}
diff --git a/ui/component/livestreamComments/index.js b/ui/component/livestreamComments/index.js
new file mode 100644
index 000000000..3c7b7a862
--- /dev/null
+++ b/ui/component/livestreamComments/index.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { makeSelectClaimForUri } from 'lbry-redux';
+import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
+import { doCommentList } from 'redux/actions/comments';
+import { makeSelectTopLevelCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments';
+import LivestreamFeed from './view';
+
+const select = (state, props) => ({
+ claim: makeSelectClaimForUri(props.uri)(state),
+ comments: makeSelectTopLevelCommentsForUri(props.uri)(state),
+ fetchingComments: selectIsFetchingComments(state),
+});
+
+export default connect(select, { doCommentSocketConnect, doCommentSocketDisconnect, doCommentList })(LivestreamFeed);
diff --git a/ui/component/livestreamComments/view.jsx b/ui/component/livestreamComments/view.jsx
new file mode 100644
index 000000000..7b94dea23
--- /dev/null
+++ b/ui/component/livestreamComments/view.jsx
@@ -0,0 +1,146 @@
+// @flow
+import React from 'react';
+import classnames from 'classnames';
+import Card from 'component/common/card';
+import Spinner from 'component/spinner';
+import CommentCreate from 'component/commentCreate';
+import Button from 'component/button';
+import MarkdownPreview from 'component/common/markdown-preview';
+
+type Props = {
+ uri: string,
+ claim: ?StreamClaim,
+ activeViewers: number,
+ embed?: boolean,
+ doCommentSocketConnect: (string, string) => void,
+ doCommentSocketDisconnect: (string) => void,
+ doCommentList: (string) => void,
+ comments: Array
,
+ fetchingComments: boolean,
+};
+
+export default function LivestreamFeed(props: Props) {
+ const {
+ claim,
+ uri,
+ embed,
+ doCommentSocketConnect,
+ doCommentSocketDisconnect,
+ comments,
+ doCommentList,
+ fetchingComments,
+ } = props;
+ const commentsRef = React.createRef();
+ const hasScrolledComments = React.useRef();
+ const [performedInitialScroll, setPerformedInitialScroll] = React.useState(false);
+ const claimId = claim && claim.claim_id;
+ const commentsLength = comments && comments.length;
+
+ React.useEffect(() => {
+ if (claimId) {
+ doCommentList(uri);
+ doCommentSocketConnect(uri, claimId);
+ }
+
+ return () => {
+ if (claimId) {
+ doCommentSocketDisconnect(claimId);
+ }
+ };
+ }, [claimId, uri, doCommentList, doCommentSocketConnect, doCommentSocketDisconnect]);
+
+ React.useEffect(() => {
+ const element = commentsRef.current;
+
+ function handleScroll() {
+ if (element) {
+ const scrollHeight = element.scrollHeight - element.offsetHeight;
+ const isAtBottom = scrollHeight === element.scrollTop;
+
+ if (!isAtBottom) {
+ hasScrolledComments.current = true;
+ } else {
+ hasScrolledComments.current = false;
+ }
+ }
+ }
+
+ if (element) {
+ element.addEventListener('scroll', handleScroll);
+
+ if (commentsLength > 0) {
+ // Only update comment scroll if the user hasn't scrolled up to view old comments
+ // If they have, do nothing
+ if (!hasScrolledComments.current || !performedInitialScroll) {
+ element.scrollTop = element.scrollHeight - element.offsetHeight;
+
+ if (!performedInitialScroll) {
+ setPerformedInitialScroll(true);
+ }
+ }
+ }
+ }
+
+ return () => {
+ if (element) {
+ element.removeEventListener('scroll', handleScroll);
+ }
+ };
+ }, [commentsRef, commentsLength, performedInitialScroll]);
+
+ if (!claim) {
+ return null;
+ }
+
+ return (
+
+ {fetchingComments && (
+
+
+
+ )}
+ 0,
+ })}
+ >
+ {!fetchingComments && comments.length > 0 ? (
+
+ {comments.map((comment) => (
+
+ {comment.channel_url ? (
+
+ ) : (
+
{comment.channel_name}
+ )}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ }
+ />
+ );
+}
diff --git a/ui/component/livestreamLayout/index.js b/ui/component/livestreamLayout/index.js
new file mode 100644
index 000000000..f6fe364e8
--- /dev/null
+++ b/ui/component/livestreamLayout/index.js
@@ -0,0 +1,10 @@
+import { connect } from 'react-redux';
+import { makeSelectClaimForUri, makeSelectThumbnailForUri } from 'lbry-redux';
+import LivestreamLayout from './view';
+
+const select = (state, props) => ({
+ claim: makeSelectClaimForUri(props.uri)(state),
+ thumbnail: makeSelectThumbnailForUri(props.uri)(state),
+});
+
+export default connect(select)(LivestreamLayout);
diff --git a/ui/component/livestreamLayout/view.jsx b/ui/component/livestreamLayout/view.jsx
new file mode 100644
index 000000000..85c35cd0c
--- /dev/null
+++ b/ui/component/livestreamLayout/view.jsx
@@ -0,0 +1,49 @@
+// @flow
+import { BITWAVE_EMBED_URL } from 'constants/livestream';
+import React from 'react';
+import FileTitleSection from 'component/fileTitleSection';
+import LivestreamComments from 'component/livestreamComments';
+
+type Props = {
+ uri: string,
+ claim: ?StreamClaim,
+ isLive: boolean,
+ activeViewers: number,
+};
+
+export default function LivestreamLayout(props: Props) {
+ const { claim, uri, isLive, activeViewers } = props;
+
+ if (!claim || !claim.signing_channel) {
+ return null;
+ }
+
+ const channelName = claim.signing_channel.name;
+ const channelClaimId = claim.signing_channel.claim_id;
+
+ return (
+ <>
+
+
+
+ {!isLive && (
+
+ {__("%channel% isn't live right now, but the chat is! Check back later to watch the stream.", {
+ channel: channelName || __('This channel'),
+ })}
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/ui/component/livestreamLink/index.js b/ui/component/livestreamLink/index.js
new file mode 100644
index 000000000..4bc23a4f0
--- /dev/null
+++ b/ui/component/livestreamLink/index.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import { makeSelectClaimForUri } from 'lbry-redux';
+import LivestreamLink from './view';
+
+const select = (state, props) => ({
+ channelClaim: makeSelectClaimForUri(props.uri)(state),
+});
+
+export default connect(select)(LivestreamLink);
diff --git a/ui/component/livestreamLink/view.jsx b/ui/component/livestreamLink/view.jsx
new file mode 100644
index 000000000..a014f215b
--- /dev/null
+++ b/ui/component/livestreamLink/view.jsx
@@ -0,0 +1,72 @@
+// @flow
+import { BITWAVE_API } from 'constants/livestream';
+import React from 'react';
+import Card from 'component/common/card';
+import ClaimPreview from 'component/claimPreview';
+import { Lbry } from 'lbry-redux';
+
+type Props = {
+ channelClaim: ChannelClaim,
+};
+
+export default function LivestreamLink(props: Props) {
+ const { channelClaim } = props;
+ const [livestreamClaim, setLivestreamClaim] = React.useState(false);
+ const [isLivestreaming, setIsLivestreaming] = React.useState(false);
+ const livestreamChannelId = channelClaim.claim_id || ''; // TODO: fail in a safer way, probably
+
+ React.useEffect(() => {
+ Lbry.claim_search({
+ channel_ids: [livestreamChannelId],
+ has_no_source: true,
+ claim_type: ['stream'],
+ })
+ .then((res) => {
+ if (res && res.items && res.items.length > 0) {
+ const claim = res.items[res.items.length - 1];
+ setLivestreamClaim(claim);
+ }
+ })
+ .catch(() => {});
+ }, [livestreamChannelId]);
+
+ React.useEffect(() => {
+ function fetchIsStreaming() {
+ // $FlowFixMe Bitwave's API can handle garbage
+ fetch(`${BITWAVE_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;
+ if (livestreamChannelId) {
+ if (!interval) fetchIsStreaming();
+ interval = setInterval(fetchIsStreaming, 10 * 1000);
+ }
+
+ return () => {
+ if (interval) {
+ clearInterval(interval);
+ }
+ };
+ }, [livestreamChannelId]);
+
+ if (!livestreamClaim || !isLivestreaming) {
+ return null;
+ }
+
+ return (
+ }
+ />
+ );
+}
diff --git a/ui/component/page/view.jsx b/ui/component/page/view.jsx
index 0ea99ef6f..4fe613deb 100644
--- a/ui/component/page/view.jsx
+++ b/ui/component/page/view.jsx
@@ -28,6 +28,7 @@ type Props = {
fullWidthPage: boolean,
videoTheaterMode: boolean,
isMarkdown?: boolean,
+ livestream?: boolean,
backout: {
backLabel?: string,
backNavDefault?: string,
@@ -49,6 +50,7 @@ function Page(props: Props) {
backout,
videoTheaterMode,
isMarkdown = false,
+ livestream,
} = props;
const {
@@ -106,8 +108,9 @@ function Page(props: Props) {
'main--full-width': fullWidthPage,
'main--auth-page': authPage,
'main--file-page': filePage,
- 'main--theater-mode': isOnFilePage && videoTheaterMode,
'main--markdown': isMarkdown,
+ 'main--theater-mode': isOnFilePage && videoTheaterMode && !livestream,
+ 'main--livestream': livestream,
})}
>
{children}
diff --git a/ui/component/publishFile/view.jsx b/ui/component/publishFile/view.jsx
index ccdd483cd..2a07b9c59 100644
--- a/ui/component/publishFile/view.jsx
+++ b/ui/component/publishFile/view.jsx
@@ -364,6 +364,7 @@ function PublishFile(props: Props) {
onFileChosen={handleFileChange}
/>
)}
+
{isPublishPost && (
void,
- resolveUri: string => void,
+ resolveUri: (string) => void,
scrollToTop: () => void,
prepareEdit: (claim: any, uri: string) => void,
resetThumbnailStatus: () => void,
amountNeededForTakeover: ?number,
// Add back type
- updatePublishForm: any => void,
- checkAvailability: string => void,
+ updatePublishForm: (any) => void,
+ checkAvailability: (string) => void,
ytSignupPending: boolean,
modal: { id: string, modalProps: {} },
enablePublishPreview: boolean,
@@ -88,13 +90,19 @@ type Props = {
};
function PublishForm(props: Props) {
- const [mode, setMode] = React.useState(PUBLISH_MODES.FILE);
+ // Detect upload type from query in URL
+ const { push, location } = useHistory();
+ const urlParams = new URLSearchParams(location.search);
+ const uploadType = urlParams.get('type');
+
+ // Component state
+ const [mode, setMode] = React.useState(uploadType || PUBLISH_MODES.FILE);
const [autoSwitchMode, setAutoSwitchMode] = React.useState(true);
- // Used to checl if the url name has changed:
+ // Used to check if the url name has changed:
// A new file needs to be provided
const [prevName, setPrevName] = React.useState(false);
- // Used to checl if the file has been modified by user
+ // Used to check if the file has been modified by user
const [fileEdited, setFileEdited] = React.useState(false);
const [prevFileText, setPrevFileText] = React.useState('');
@@ -225,17 +233,61 @@ function PublishForm(props: Props) {
}, [name, activeChannelName, resolveUri, updatePublishForm, checkAvailability]);
useEffect(() => {
- updatePublishForm({ isMarkdownPost: mode === PUBLISH_MODES.POST });
+ updatePublishForm({
+ isMarkdownPost: mode === PUBLISH_MODES.POST,
+ isLivestreamPublish: mode === PUBLISH_MODES.LIVESTREAM,
+ });
}, [mode, updatePublishForm]);
useEffect(() => {
if (incognito) {
updatePublishForm({ channel: undefined });
+
+ // Anonymous livestreams aren't supported
+ if (mode === PUBLISH_MODES.LIVESTREAM) {
+ setMode(PUBLISH_MODES.FILE);
+ }
} else if (activeChannelName) {
updatePublishForm({ channel: activeChannelName });
}
}, [activeChannelName, incognito, updatePublishForm]);
+ useEffect(() => {
+ const _uploadType = uploadType && uploadType.toLowerCase();
+
+ // Default to standard file publish if none specified
+ if (!_uploadType) {
+ setMode(PUBLISH_MODES.FILE);
+ return;
+ }
+
+ // File publish
+ if (_uploadType === PUBLISH_MODES.FILE.toLowerCase()) {
+ setMode(PUBLISH_MODES.FILE);
+ return;
+ }
+ // Post publish
+ if (_uploadType === PUBLISH_MODES.POST.toLowerCase()) {
+ setMode(PUBLISH_MODES.POST);
+ return;
+ }
+ // LiveStream publish
+ if (_uploadType === PUBLISH_MODES.LIVESTREAM.toLowerCase()) {
+ setMode(PUBLISH_MODES.LIVESTREAM);
+ return;
+ }
+
+ // Default to standard file publish
+ setMode(PUBLISH_MODES.FILE);
+ }, [uploadType]);
+
+ useEffect(() => {
+ if (!uploadType) return;
+ const newParams = new URLSearchParams();
+ newParams.set('type', mode.toLowerCase());
+ push({ search: newParams.toString() });
+ }, [mode, uploadType]);
+
// @if TARGET='web'
function createWebFile() {
if (fileText) {
@@ -301,7 +353,7 @@ function PublishForm(props: Props) {
}
}
// Publish file
- if (mode === PUBLISH_MODES.FILE) {
+ if (mode === PUBLISH_MODES.FILE || mode === PUBLISH_MODES.LIVESTREAM) {
runPublish = true;
}
@@ -330,7 +382,7 @@ function PublishForm(props: Props) {
// Editing claim uri
return (
-
+
{
+ // $FlowFixMe
setMode(modeName);
}}
className={classnames('button-toggle', { 'button-toggle--active': mode === modeName })}
@@ -373,17 +426,17 @@ function PublishForm(props: Props) {
"Add tags that are relevant to your content so those who're looking for it can find it more easily. If mature content, ensure it is tagged mature. Tag abuse and missing mature tags will not be tolerated."
)}
placeholder={__('gaming, crypto')}
- onSelect={newTags => {
+ onSelect={(newTags) => {
const validatedTags = [];
- newTags.forEach(newTag => {
- if (!tags.some(tag => tag.name === newTag.name)) {
+ newTags.forEach((newTag) => {
+ if (!tags.some((tag) => tag.name === newTag.name)) {
validatedTags.push(newTag);
}
});
updatePublishForm({ tags: [...tags, ...validatedTags] });
}}
- onRemove={clickedTag => {
- const newTags = tags.slice().filter(tag => tag.name !== clickedTag.name);
+ onRemove={(clickedTag) => {
+ const newTags = tags.slice().filter((tag) => tag.name !== clickedTag.name);
updatePublishForm({ tags: newTags });
}}
tagsChosen={tags}
diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx
index 63918e4d5..056127b7a 100644
--- a/ui/component/router/view.jsx
+++ b/ui/component/router/view.jsx
@@ -36,6 +36,7 @@ import PasswordResetPage from 'page/passwordReset';
import PasswordSetPage from 'page/passwordSet';
import SignInVerifyPage from 'page/signInVerify';
import ChannelsPage from 'page/channels';
+import LiveStreamSetupPage from 'page/livestreamSetup';
import EmbedWrapperPage from 'page/embedWrapper';
import TopPage from 'page/top';
import Welcome from 'page/welcome';
@@ -275,6 +276,7 @@ function AppRouter(props: Props) {
+
diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js
index 63881cb50..eeb0e0a6d 100644
--- a/ui/constants/action_types.js
+++ b/ui/constants/action_types.js
@@ -277,6 +277,7 @@ export const COMMENT_MODERATION_BLOCK_FAILED = 'COMMENT_MODERATION_BLOCK_FAILED'
export const COMMENT_MODERATION_UN_BLOCK_STARTED = 'COMMENT_MODERATION_UN_BLOCK_STARTED';
export const COMMENT_MODERATION_UN_BLOCK_COMPLETE = 'COMMENT_MODERATION_UN_BLOCK_COMPLETE';
export const COMMENT_MODERATION_UN_BLOCK_FAILED = 'COMMENT_MODERATION_UN_BLOCK_FAILED';
+export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
// Blocked channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
diff --git a/ui/constants/icons.js b/ui/constants/icons.js
index 2ba6c2524..6b43cb5c0 100644
--- a/ui/constants/icons.js
+++ b/ui/constants/icons.js
@@ -97,6 +97,7 @@ export const MORE_VERTICAL = 'MoreVertical';
export const IMAGE = 'Image';
export const AUDIO = 'HeadPhones';
export const VIDEO = 'Video';
+export const LIVESTREAM = 'Livestream';
export const VOLUME_MUTED = 'VolumeX';
export const TEXT = 'FileText';
export const DOWNLOADABLE = 'Downloadable';
diff --git a/ui/constants/livestream.js b/ui/constants/livestream.js
new file mode 100644
index 000000000..fd0aca3a4
--- /dev/null
+++ b/ui/constants/livestream.js
@@ -0,0 +1,2 @@
+export const BITWAVE_EMBED_URL = 'https://bitwave.tv/odysee';
+export const BITWAVE_API = 'https://api.bitwave.tv/v1/odysee/live';
diff --git a/ui/constants/pages.js b/ui/constants/pages.js
index ea38aa973..20c951986 100644
--- a/ui/constants/pages.js
+++ b/ui/constants/pages.js
@@ -60,3 +60,4 @@ exports.BUY = 'buy';
exports.CHANNEL_NEW = 'channel/new';
exports.NOTIFICATIONS = 'notifications';
exports.YOUTUBE_SYNC = 'youtube';
+exports.LIVESTREAM = 'livestream';
diff --git a/ui/constants/publish_types.js b/ui/constants/publish_types.js
index c438709a8..397bd6046 100644
--- a/ui/constants/publish_types.js
+++ b/ui/constants/publish_types.js
@@ -1,2 +1,3 @@
export const FILE = 'File';
export const POST = 'Post';
+export const LIVESTREAM = 'Livestream';
diff --git a/ui/page/livestream/index.js b/ui/page/livestream/index.js
new file mode 100644
index 000000000..0b581df73
--- /dev/null
+++ b/ui/page/livestream/index.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { doResolveUri, makeSelectClaimForUri } from 'lbry-redux';
+import { doSetPlayingUri } from 'redux/actions/content';
+import { doUserSetReferrer } from 'redux/actions/user';
+import { selectUserVerifiedEmail } from 'redux/selectors/user';
+import { selectHasUnclaimedRefereeReward } from 'redux/selectors/rewards';
+import LivestreamPage from './view';
+
+const select = (state, props) => ({
+ hasUnclaimedRefereeReward: selectHasUnclaimedRefereeReward(state),
+ isAuthenticated: selectUserVerifiedEmail(state),
+ channelClaim: makeSelectClaimForUri(props.uri)(state),
+});
+
+export default connect(select, {
+ doSetPlayingUri,
+ doResolveUri,
+ doUserSetReferrer,
+})(LivestreamPage);
diff --git a/ui/page/livestream/view.jsx b/ui/page/livestream/view.jsx
new file mode 100644
index 000000000..1d70dd7c7
--- /dev/null
+++ b/ui/page/livestream/view.jsx
@@ -0,0 +1,87 @@
+// @flow
+import { BITWAVE_API } from 'constants/livestream';
+import React from 'react';
+import Page from 'component/page';
+import LivestreamLayout from 'component/livestreamLayout';
+import analytics from 'analytics';
+
+type Props = {
+ uri: string,
+ claim: StreamClaim,
+ doSetPlayingUri: ({ uri: ?string }) => void,
+ isAuthenticated: boolean,
+ doUserSetReferrer: (string) => void,
+ channelClaim: ChannelClaim,
+};
+
+export default function LivestreamPage(props: Props) {
+ const { uri, claim, doSetPlayingUri, isAuthenticated, doUserSetReferrer, channelClaim } = props;
+ const [activeViewers, setActiveViewers] = React.useState(0);
+ const [isLive, setIsLive] = React.useState(false);
+ const livestreamChannelId = channelClaim && channelClaim.signing_channel && channelClaim.signing_channel.claim_id;
+
+ React.useEffect(() => {
+ function checkIsLive() {
+ // $FlowFixMe Bitwave's API can handle garbage
+ fetch(`${BITWAVE_API}/${livestreamChannelId}`)
+ .then((res) => res.json())
+ .then((res) => {
+ if (!res || !res.data) {
+ setIsLive(false);
+ return;
+ }
+
+ setActiveViewers(res.data.viewCount);
+
+ if (res.data.hasOwnProperty('live')) {
+ setIsLive(res.data.live);
+ }
+ });
+ }
+
+ let interval;
+ if (livestreamChannelId) {
+ if (!interval) checkIsLive();
+ interval = setInterval(checkIsLive, 10 * 1000);
+ }
+
+ return () => {
+ if (interval) {
+ clearInterval(interval);
+ }
+ };
+ }, [livestreamChannelId]);
+
+ const stringifiedClaim = JSON.stringify(claim);
+ React.useEffect(() => {
+ if (uri && stringifiedClaim) {
+ const jsonClaim = JSON.parse(stringifiedClaim);
+
+ if (jsonClaim) {
+ const { txid, nout, claim_id: claimId } = jsonClaim;
+ const outpoint = `${txid}:${nout}`;
+
+ analytics.apiLogView(uri, outpoint, claimId);
+ }
+
+ if (!isAuthenticated) {
+ const uri = jsonClaim.signing_channel && jsonClaim.signing_channel.permanent_url;
+ if (uri) {
+ doUserSetReferrer(uri.replace('lbry://', ''));
+ }
+ }
+ }
+ }, [uri, stringifiedClaim, isAuthenticated]);
+
+ React.useEffect(() => {
+ // Set playing uri to null so the popout player doesnt start playing the dummy claim if a user navigates back
+ // This can be removed when we start using the app video player, not a bitwave iframe
+ doSetPlayingUri({ uri: null });
+ }, [doSetPlayingUri]);
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/page/livestreamSetup/index.js b/ui/page/livestreamSetup/index.js
new file mode 100644
index 000000000..68bf859c1
--- /dev/null
+++ b/ui/page/livestreamSetup/index.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
+import { selectActiveChannelClaim } from 'redux/selectors/app';
+import LivestreamSetupPage from './view';
+
+const select = (state) => ({
+ channels: selectMyChannelClaims(state),
+ fetchingChannels: selectFetchingMyChannels(state),
+ activeChannelClaim: selectActiveChannelClaim(state),
+});
+
+export default connect(select)(LivestreamSetupPage);
diff --git a/ui/page/livestreamSetup/view.jsx b/ui/page/livestreamSetup/view.jsx
new file mode 100644
index 000000000..fb1b8b0a3
--- /dev/null
+++ b/ui/page/livestreamSetup/view.jsx
@@ -0,0 +1,210 @@
+// @flow
+import * as PAGES from 'constants/pages';
+import * as PUBLISH_MODES from 'constants/publish_types';
+import React from 'react';
+import Page from 'component/page';
+import Spinner from 'component/spinner';
+import Button from 'component/button';
+import ChannelSelector from 'component/channelSelector';
+import Yrbl from 'component/yrbl';
+import { Lbry } from 'lbry-redux';
+import { toHex } from 'util/hex';
+import { FormField } from 'component/common/form';
+import CopyableText from 'component/copyableText';
+import Card from 'component/common/card';
+import ClaimList from 'component/claimList';
+
+type Props = {
+ channels: Array,
+ fetchingChannels: boolean,
+ activeChannelClaim: ?ChannelClaim,
+};
+
+export default function LivestreamSetupPage(props: Props) {
+ const { channels, fetchingChannels, activeChannelClaim } = props;
+
+ const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined });
+
+ const hasChannels = channels && channels.length > 0;
+ const activeChannelClaimStr = JSON.stringify(activeChannelClaim);
+ const streamKey = createStreamKey();
+
+ React.useEffect(() => {
+ if (activeChannelClaimStr) {
+ const channelClaim = JSON.parse(activeChannelClaimStr);
+
+ // ensure we have a channel
+ if (channelClaim.claim_id) {
+ Lbry.channel_sign({
+ channel_id: channelClaim.claim_id,
+ hexdata: toHex(channelClaim.name),
+ })
+ .then((data) => {
+ setSigData(data);
+ })
+ .catch((error) => {
+ setSigData({ signature: null, signing_ts: null });
+ });
+ }
+ }
+ }, [activeChannelClaimStr, setSigData]);
+
+ function createStreamKey() {
+ if (!activeChannelClaim || !sigData.signature || !sigData.signing_ts) return null;
+ return `${activeChannelClaim.claim_id}?d=${toHex(activeChannelClaim.name)}&s=${sigData.signature}&t=${
+ sigData.signing_ts
+ }`;
+ }
+
+ const [livestreamClaims, setLivestreamClaims] = React.useState([]);
+
+ React.useEffect(() => {
+ if (!activeChannelClaimStr) return;
+
+ const channelClaim = JSON.parse(activeChannelClaimStr);
+
+ Lbry.claim_search({
+ channel_ids: [channelClaim.claim_id],
+ has_no_source: true,
+ claim_type: ['stream'],
+ })
+ .then((res) => {
+ if (res && res.items && res.items.length > 0) {
+ setLivestreamClaims(res.items.reverse());
+ } else {
+ setLivestreamClaims([]);
+ }
+ })
+ .catch(() => {
+ setLivestreamClaims([]);
+ });
+ }, [activeChannelClaimStr]);
+
+ return (
+
+ {fetchingChannels && (
+
+
+
+ )}
+
+ {!fetchingChannels && !hasChannels && (
+
+
+
+ }
+ />
+ )}
+
+
+ {!fetchingChannels && activeChannelClaim && (
+ <>
+
+
+ {streamKey && livestreamClaims.length > 0 && (
+
+
+
+ >
+ }
+ />
+ )}
+
+ {livestreamClaims.length > 0 ? (
+ claim.permanent_url)}
+ />
+ ) : (
+
+
+
+ }
+ />
+ )}
+
+ {/* Debug Stuff */}
+ {streamKey && false && (
+
+
Debug Info
+
+ {/* Channel ID */}
+
+
+ {/* Signature */}
+
+
+ {/* Signature TS */}
+
+
+ {/* Hex Data */}
+
+
+ {/* Channel Public Key */}
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/ui/page/show/index.js b/ui/page/show/index.js
index b740047e4..76fcdae0a 100644
--- a/ui/page/show/index.js
+++ b/ui/page/show/index.js
@@ -10,6 +10,7 @@ import {
normalizeURI,
makeSelectClaimIsMine,
makeSelectClaimIsPending,
+ makeSelectClaimHasSource,
} from 'lbry-redux';
import { makeSelectChannelInSubscriptions } from 'redux/selectors/subscriptions';
import { selectBlackListedOutpoints } from 'lbryinc';
@@ -60,11 +61,12 @@ const select = (state, props) => {
title: makeSelectTitleForUri(uri)(state),
claimIsMine: makeSelectClaimIsMine(uri)(state),
claimIsPending: makeSelectClaimIsPending(uri)(state),
+ isLivestream: !makeSelectClaimHasSource(uri)(state),
};
};
-const perform = dispatch => ({
- resolveUri: uri => dispatch(doResolveUri(uri)),
+const perform = (dispatch) => ({
+ resolveUri: (uri) => dispatch(doResolveUri(uri)),
});
export default connect(select, perform)(ShowPage);
diff --git a/ui/page/show/view.jsx b/ui/page/show/view.jsx
index cb64c8b1c..0e53c6ba5 100644
--- a/ui/page/show/view.jsx
+++ b/ui/page/show/view.jsx
@@ -5,6 +5,7 @@ import { Redirect } from 'react-router-dom';
import Spinner from 'component/spinner';
import ChannelPage from 'page/channel';
import FilePage from 'page/file';
+import LivestreamPage from 'page/livestream';
import Page from 'component/page';
import Button from 'component/button';
import Card from 'component/common/card';
@@ -25,6 +26,7 @@ type Props = {
title: string,
claimIsMine: boolean,
claimIsPending: boolean,
+ isLivestream: boolean,
};
function ShowPage(props: Props) {
@@ -38,6 +40,7 @@ function ShowPage(props: Props) {
claimIsMine,
isSubscribed,
claimIsPending,
+ isLivestream,
} = props;
const signingChannel = claim && claim.signing_channel;
@@ -119,6 +122,10 @@ function ShowPage(props: Props) {
/>
);
+ }
+
+ if (isLivestream) {
+ innerContent =