Feat publish replays on master (#5863)

* provide livestream replay publish via url
This commit is contained in:
jessopb 2021-04-14 00:06:11 -04:00 committed by GitHub
parent 9f3d779cf2
commit 989126c603
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 674 additions and 150 deletions

View file

@ -34,6 +34,7 @@ ENABLE_NO_SOURCE_CLAIMS=false
ENABLE_PREROLL_ADS=false ENABLE_PREROLL_ADS=false
CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS=4 CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS=4
CHANNEL_STAKED_LEVEL_LIVESTREAM=5 CHANNEL_STAKED_LEVEL_LIVESTREAM=5
WEB_PUBLISH_SIZE_LIMIT_GB=4
# OG # OG
OG_TITLE_SUFFIX=| lbry.tv OG_TITLE_SUFFIX=| lbry.tv

View file

@ -39,6 +39,7 @@ const config = {
ENABLE_PREROLL_ADS: process.env.ENABLE_PREROLL_ADS === 'true', ENABLE_PREROLL_ADS: process.env.ENABLE_PREROLL_ADS === 'true',
CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS: process.env.CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS: process.env.CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS,
CHANNEL_STAKED_LEVEL_LIVESTREAM: process.env.CHANNEL_STAKED_LEVEL_LIVESTREAM, CHANNEL_STAKED_LEVEL_LIVESTREAM: process.env.CHANNEL_STAKED_LEVEL_LIVESTREAM,
WEB_PUBLISH_SIZE_LIMIT_GB: process.env.WEB_PUBLISH_SIZE_LIMIT_GB,
SIMPLE_SITE: process.env.SIMPLE_SITE === 'true', SIMPLE_SITE: process.env.SIMPLE_SITE === 'true',
SHOW_ADS: process.env.SHOW_ADS === 'true', SHOW_ADS: process.env.SHOW_ADS === 'true',
PINNED_URI_1: process.env.PINNED_URI_1, PINNED_URI_1: process.env.PINNED_URI_1,

22
flow-typed/livestream.js vendored Normal file
View file

@ -0,0 +1,22 @@
// @flow
declare type LivestreamReplayItem = {
data: {
claimId: string,
deleted: boolean,
deletedAt: ?string,
ffprobe: any,
fileDuration: number, // decimal? float? string?
fileType: string,
fileLocation: string,
fileSize: number,
key: string,
published: boolean,
publishedAt: ?string,
service: string,
thumbnails: Array<string>,
uploadedAt: string, // Date?
},
id: string,
}
declare type LivestreamReplayData = Array<LivestreamReplayItem>;

View file

@ -1,15 +1,4 @@
// @flow // @flow
import * as ACTIONS from 'constants/action_types';
import {
DOWNLOADED,
DOWNLOADING,
NOTIFY_ONLY,
VIEW_ALL,
VIEW_LATEST_FIRST,
SUGGESTED_TOP_BID,
SUGGESTED_TOP_SUBSCRIBED,
SUGGESTED_FEATURED,
} from 'constants/subscriptions';
declare type Subscription = { declare type Subscription = {
channelName: string, // @CryptoCandor, channelName: string, // @CryptoCandor,

View file

@ -1778,5 +1778,10 @@
"Publishing...": "Publishing...", "Publishing...": "Publishing...",
"Collection": "Collection", "Collection": "Collection",
"More from %claim_name%": "More from %claim_name%", "More from %claim_name%": "More from %claim_name%",
"Upload that unlabeled video you found behind the TV in 1991": "Upload that unlabeled video you found behind the TV in 1991",
"Select Replay": "Select Replay",
"Craft an epic post clearly explaining... whatever.": "Craft an epic post clearly explaining... whatever.",
"%viewer_count% currently %viewer_state%": "%viewer_count% currently %viewer_state%",
"Choose Replay": "Choose Replay",
"--end--": "--end--" "--end--": "--end--"
} }

View file

@ -21,7 +21,7 @@ type Props = {
nag?: Node, nag?: Node,
smallTitle?: boolean, smallTitle?: boolean,
onClick?: () => void, onClick?: () => void,
children?: any, // not sure how this works children?: Node,
}; };
export default function Card(props: Props) { export default function Card(props: Props) {

View file

@ -3,7 +3,6 @@ import { SIMPLE_SITE, SITE_NAME, ENABLE_FILE_REACTIONS } from 'config';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types'; import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PUBLISH_MODES from 'constants/publish_types';
import React from 'react'; import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import FileDownloadLink from 'component/fileDownloadLink'; import FileDownloadLink from 'component/fileDownloadLink';
@ -114,25 +113,13 @@ function FileActions(props: Props) {
<Button <Button
className="button--file-action" className="button--file-action"
icon={ICONS.EDIT} icon={ICONS.EDIT}
label={__('Edit')} label={isLivestreamClaim ? __('Update') : __('Edit')}
navigate={`/$/${PAGES.UPLOAD}${isLivestreamClaim ? `?type=${PUBLISH_MODES.LIVESTREAM}` : ''}`} navigate={`/$/${PAGES.UPLOAD}`}
onClick={() => { onClick={() => {
prepareEdit(claim, editUri, fileInfo); prepareEdit(claim, editUri, fileInfo);
}} }}
/> />
)} )}
{claimIsMine && isLivestreamClaim && (
<Button
className="button--file-action"
icon={ICONS.PUBLISH}
label={__('Publish Replay')}
navigate={`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.FILE}`}
onClick={() => {
prepareEdit(claim, editUri, fileInfo);
}}
/>
)}
{showDelete && ( {showDelete && (
<Button <Button
title={__('Remove from your library')} title={__('Remove from your library')}

View file

@ -1,5 +1,5 @@
// @flow // @flow
import { BITWAVE_API } from 'constants/livestream'; import { BITWAVE_LIVE_API } from 'constants/livestream';
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';
@ -35,7 +35,7 @@ export default function LivestreamLink(props: Props) {
React.useEffect(() => { React.useEffect(() => {
function fetchIsStreaming() { function fetchIsStreaming() {
// $FlowFixMe Bitwave's API can handle garbage // $FlowFixMe Bitwave's API can handle garbage
fetch(`${BITWAVE_API}/${livestreamChannelId}`) fetch(`${BITWAVE_LIVE_API}/${livestreamChannelId}`)
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
if (res && res.success && res.data && res.data.live) { if (res && res.success && res.data && res.data.live) {

View file

@ -5,15 +5,17 @@ import {
makeSelectPublishFormValue, makeSelectPublishFormValue,
doUpdatePublishForm, doUpdatePublishForm,
doClearPublish, doClearPublish,
makeSelectClaimIsStreamPlaceholder,
} from 'lbry-redux'; } from 'lbry-redux';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { selectFfmpegStatus } from 'redux/selectors/settings'; import { selectFfmpegStatus } from 'redux/selectors/settings';
import PublishPage from './view'; import PublishPage from './view';
const select = state => ({ const select = (state, props) => ({
name: makeSelectPublishFormValue('name')(state), name: makeSelectPublishFormValue('name')(state),
title: makeSelectPublishFormValue('title')(state), title: makeSelectPublishFormValue('title')(state),
filePath: makeSelectPublishFormValue('filePath')(state), filePath: makeSelectPublishFormValue('filePath')(state),
remoteUrl: makeSelectPublishFormValue('remoteFileUrl')(state),
optimize: makeSelectPublishFormValue('optimize')(state), optimize: makeSelectPublishFormValue('optimize')(state),
isStillEditing: selectIsStillEditing(state), isStillEditing: selectIsStillEditing(state),
balance: selectBalance(state), balance: selectBalance(state),
@ -22,12 +24,13 @@ const select = state => ({
size: makeSelectPublishFormValue('fileSize')(state), size: makeSelectPublishFormValue('fileSize')(state),
duration: makeSelectPublishFormValue('fileDur')(state), duration: makeSelectPublishFormValue('fileDur')(state),
isVid: makeSelectPublishFormValue('fileVid')(state), isVid: makeSelectPublishFormValue('fileVid')(state),
isLivestreamClaim: makeSelectClaimIsStreamPlaceholder(props.uri)(state),
}); });
const perform = dispatch => ({ const perform = (dispatch) => ({
clearPublish: () => dispatch(doClearPublish()), clearPublish: () => dispatch(doClearPublish()),
updatePublishForm: value => dispatch(doUpdatePublishForm(value)), updatePublishForm: (value) => dispatch(doUpdatePublishForm(value)),
showToast: message => dispatch(doToast({ message, isError: true })), showToast: (message) => dispatch(doToast({ message, isError: true })),
}); });
export default connect(select, perform)(PublishPage); export default connect(select, perform)(PublishPage);

View file

@ -1,5 +1,5 @@
// @flow // @flow
import { SITE_NAME } from 'config'; import { SITE_NAME, WEB_PUBLISH_SIZE_LIMIT_GB } from 'config';
import type { Node } from 'react'; import type { Node } from 'react';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
@ -14,6 +14,11 @@ import I18nMessage from 'component/i18nMessage';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import * as PUBLISH_MODES from 'constants/publish_types'; import * as PUBLISH_MODES from 'constants/publish_types';
import PublishName from 'component/publishName'; import PublishName from 'component/publishName';
import CopyableText from 'component/copyableText';
import Empty from 'component/common/empty';
import moment from 'moment';
import classnames from 'classnames';
import ReactPaginate from 'react-paginate';
type Props = { type Props = {
uri: ?string, uri: ?string,
@ -35,9 +40,17 @@ type Props = {
size: number, size: number,
duration: number, duration: number,
isVid: boolean, isVid: boolean,
subtitle: string,
setPublishMode: (string) => void, setPublishMode: (string) => void,
setPrevFileText: (string) => void, setPrevFileText: (string) => void,
header: Node, header: Node,
livestreamData: LivestreamReplayData,
isLivestreamClaim: boolean,
checkLivestreams: (string, ?string, ?string) => void,
channelId: string,
channelSignature: { signature?: string, signing_ts?: string },
isCheckingLivestreams: boolean,
setWaitForFile: (boolean) => void,
}; };
function PublishFile(props: Props) { function PublishFile(props: Props) {
@ -63,29 +76,60 @@ function PublishFile(props: Props) {
setPublishMode, setPublishMode,
setPrevFileText, setPrevFileText,
header, header,
livestreamData,
isLivestreamClaim,
subtitle,
checkLivestreams,
channelId,
channelSignature,
isCheckingLivestreams,
setWaitForFile,
} = props; } = props;
const SOURCE_NONE = 'none';
const SOURCE_SELECT = 'select';
const SOURCE_UPLOAD = 'upload';
const RECOMMENDED_BITRATE = 6000000;
const TV_PUBLISH_SIZE_LIMIT_BYTES = WEB_PUBLISH_SIZE_LIMIT_GB * 1073741824;
const TV_PUBLISH_SIZE_LIMIT_GB_STR = String(WEB_PUBLISH_SIZE_LIMIT_GB);
const PROCESSING_MB_PER_SECOND = 0.5;
const MINUTES_THRESHOLD = 30;
const HOURS_THRESHOLD = MINUTES_THRESHOLD * 60;
const MARKDOWN_FILE_EXTENSIONS = ['txt', 'md', 'markdown'];
const sizeInMB = Number(size) / 1000000;
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND;
const ffmpegAvail = ffmpegStatus.available; const ffmpegAvail = ffmpegStatus.available;
const [oversized, setOversized] = useState(false); const [oversized, setOversized] = useState(false);
const [currentFile, setCurrentFile] = useState(null); const [currentFile, setCurrentFile] = useState(null);
const [currentFileType, setCurrentFileType] = useState(null); const [currentFileType, setCurrentFileType] = useState(null);
const [optimizeAvail, setOptimizeAvail] = useState(false); const [optimizeAvail, setOptimizeAvail] = useState(false);
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false); const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
const RECOMMENDED_BITRATE = 6000000;
const TV_PUBLISH_SIZE_LIMIT: number = 4294967296;
const TV_PUBLISH_SIZE_LIMIT_STR_GB = '4';
const UPLOAD_SIZE_MESSAGE = __( const UPLOAD_SIZE_MESSAGE = __(
'%SITE_NAME% uploads are limited to %limit% GB. Download the app for unrestricted publishing.', '%SITE_NAME% uploads are limited to %limit% GB. Download the app for unrestricted publishing.',
{ SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_STR_GB } { SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_GB_STR }
); );
const PROCESSING_MB_PER_SECOND = 0.5;
const MINUTES_THRESHOLD = 30;
const HOURS_THRESHOLD = MINUTES_THRESHOLD * 60;
const MARKDOWN_FILE_EXTENSIONS = ['txt', 'md', 'markdown'];
const sizeInMB = Number(size) / 1000000; const fileSelectorModes = [
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND; { label: __('Choose Replay'), actionName: SOURCE_SELECT, icon: ICONS.MENU },
{ label: __('Upload'), actionName: SOURCE_UPLOAD, icon: ICONS.PUBLISH },
{ label: __('None'), actionName: SOURCE_NONE },
];
const livestreamDataStr = JSON.stringify(livestreamData);
const hasLivestreamData = livestreamData && Boolean(livestreamData.length);
const showSourceSelector = isLivestreamClaim;
const [fileSelectSource, setFileSelectSource] = useState(
IS_WEB && showSourceSelector ? SOURCE_SELECT : SOURCE_UPLOAD
);
// const [showFileUpdate, setShowFileUpdate] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState(null);
const PAGE_SIZE = 4;
const [currentPage, setCurrentPage] = useState(1);
const totalPages =
hasLivestreamData && livestreamData.length > PAGE_SIZE ? Math.ceil(livestreamData.length / PAGE_SIZE) : 1;
// Reset filePath if publish mode changed // Reset filePath if publish mode changed
useEffect(() => { useEffect(() => {
@ -93,9 +137,42 @@ function PublishFile(props: Props) {
if (currentFileType !== 'text/markdown' && !isStillEditing) { if (currentFileType !== 'text/markdown' && !isStillEditing) {
updatePublishForm({ filePath: '' }); updatePublishForm({ filePath: '' });
} }
} else if (mode === PUBLISH_MODES.LIVESTREAM) {
updatePublishForm({ filePath: '' });
} }
}, [currentFileType, mode, isStillEditing, updatePublishForm]); }, [currentFileType, mode, isStillEditing, updatePublishForm]);
// set default file source to select if necessary
useEffect(() => {
if (hasLivestreamData && isLivestreamClaim) {
setWaitForFile(true);
setFileSelectSource(SOURCE_SELECT);
} else if (isLivestreamClaim) {
setFileSelectSource(SOURCE_NONE);
}
}, [hasLivestreamData, isLivestreamClaim, setFileSelectSource]);
const normalizeUrlForProtocol = (url) => {
if (url.startsWith('https://')) {
return url;
} else {
if (url.startsWith('http://')) {
return url;
} else {
return `https://${url}`;
}
}
};
// update remoteUrl when replay selected
useEffect(() => {
const livestreamData = JSON.parse(livestreamDataStr);
if (selectedFileIndex !== null && livestreamData && livestreamData.length) {
updatePublishForm({
remoteFileUrl: normalizeUrlForProtocol(livestreamData[selectedFileIndex].data.fileLocation),
});
}
}, [selectedFileIndex, updatePublishForm, livestreamDataStr]);
useEffect(() => { useEffect(() => {
if (!filePath || filePath === '') { if (!filePath || filePath === '') {
setCurrentFile(''); setCurrentFile('');
@ -121,6 +198,10 @@ function PublishFile(props: Props) {
updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid }); updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid });
} }
function handlePaginateReplays(page) {
setCurrentPage(page);
}
function getBitrate(size, duration) { function getBitrate(size, duration) {
const s = Number(size); const s = Number(size);
const d = Number(duration); const d = Number(duration);
@ -154,7 +235,7 @@ function PublishFile(props: Props) {
} }
} }
function getMessage() { function getUploadMessage() {
// @if TARGET='web' // @if TARGET='web'
if (oversized) { if (oversized) {
return ( return (
@ -186,6 +267,11 @@ function PublishFile(props: Props) {
} }
if (!!isStillEditing && name) { if (!!isStillEditing && name) {
if (isLivestreamClaim) {
return (
<p className="help">{__('You can upload your own recording or select a replay when your stream is over')}</p>
);
}
return ( return (
<p className="help"> <p className="help">
{__("If you don't choose a file, the file from your existing claim %name% will be used", { name: name })} {__("If you don't choose a file, the file from your existing claim %name% will be used", { name: name })}
@ -198,7 +284,7 @@ function PublishFile(props: Props) {
<p className="help"> <p className="help">
{__( {__(
'For video content, use MP4s in H264/AAC format and a friendly bitrate (under 5 Mbps) and resolution (720p) for more reliable streaming. %SITE_NAME% uploads are restricted to %limit% GB.', 'For video content, use MP4s in H264/AAC format and a friendly bitrate (under 5 Mbps) and resolution (720p) for more reliable streaming. %SITE_NAME% uploads are restricted to %limit% GB.',
{ SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_STR_GB } { SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_GB_STR }
)}{' '} )}{' '}
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" /> <Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p> </p>
@ -225,6 +311,27 @@ function PublishFile(props: Props) {
return newName.replace(INVALID_URI_CHARS, '-'); return newName.replace(INVALID_URI_CHARS, '-');
} }
function handleFileSource(source) {
if (source === SOURCE_NONE) {
// clear files and remotes...
// https://github.com/lbryio/lbry-desktop/issues/5855
// publish is trying to use one field to share html file blob and string and such
// $FlowFixMe
handleFileChange(false);
updatePublishForm({ remoteFileUrl: undefined });
} else if (source === SOURCE_UPLOAD) {
updatePublishForm({ remoteFileUrl: undefined });
} else if (source === SOURCE_SELECT) {
// $FlowFixMe
handleFileChange(false);
if (selectedFileIndex !== null) {
updatePublishForm({ remoteFileUrl: livestreamData[selectedFileIndex].data.fileLocation });
}
}
setFileSelectSource(source);
setWaitForFile(source !== SOURCE_NONE);
}
function handleTitleChange(event) { function handleTitleChange(event) {
const title = event.target.value; const title = event.target.value;
// Update title // Update title
@ -247,7 +354,11 @@ function PublishFile(props: Props) {
// select file, start to select a new one, then cancel // select file, start to select a new one, then cancel
if (!file) { if (!file) {
if (isStillEditing) {
updatePublishForm({ filePath: '' });
} else {
updatePublishForm({ filePath: '', name: '' }); updatePublishForm({ filePath: '', name: '' });
}
return; return;
} }
@ -301,7 +412,7 @@ function PublishFile(props: Props) {
// @if TARGET='web' // @if TARGET='web'
// we only need to enforce file sizes on 'web' // we only need to enforce file sizes on 'web'
if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT) { if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT_BYTES) {
setOversized(true); setOversized(true);
showToast(__(UPLOAD_SIZE_MESSAGE)); showToast(__(UPLOAD_SIZE_MESSAGE));
updatePublishForm({ filePath: '', name: '' }); updatePublishForm({ filePath: '', name: '' });
@ -326,7 +437,7 @@ function PublishFile(props: Props) {
updatePublishForm(publishFormParams); updatePublishForm(publishFormParams);
} }
const isPublishFile = mode === PUBLISH_MODES.FILE; const showFileUpload = mode === PUBLISH_MODES.FILE || (mode === PUBLISH_MODES.LIVESTREAM && hasLivestreamData);
const isPublishPost = mode === PUBLISH_MODES.POST; const isPublishPost = mode === PUBLISH_MODES.POST;
return ( return (
@ -334,7 +445,7 @@ function PublishFile(props: Props) {
className={disabled || balance === 0 ? 'card--disabled' : ''} className={disabled || balance === 0 ? 'card--disabled' : ''}
title={ title={
<div> <div>
{header} {header} {/* display mode buttons from parent */}
{publishing && <Spinner type={'small'} />} {publishing && <Spinner type={'small'} />}
{inProgress && ( {inProgress && (
<div> <div>
@ -343,10 +454,10 @@ function PublishFile(props: Props) {
)} )}
</div> </div>
} }
subtitle={isStillEditing && __('You are currently editing your upload.')} subtitle={subtitle || (isStillEditing && __('You are currently editing your upload.'))}
actions={ actions={
<React.Fragment> <React.Fragment>
<PublishName /> <PublishName uri={uri} />
<FormField <FormField
type="text" type="text"
name="content_title" name="content_title"
@ -356,28 +467,161 @@ function PublishFile(props: Props) {
value={title} value={title}
onChange={handleTitleChange} onChange={handleTitleChange}
/> />
{isPublishFile && ( {/* Decide whether to show file upload or replay selector */}
{/* @if TARGET='web' */}
<>
{showSourceSelector && (
<fieldset-section>
<div className="section__actions--between section__actions--align-bottom">
<div>
<label>{__('Add replay video')}</label>
<div className="button-group">
{fileSelectorModes.map((fmode) => (
<Button
key={fmode.label}
icon={fmode.icon || undefined}
iconSize={18}
label={fmode.label}
button="alt"
onClick={() => {
// $FlowFixMe
handleFileSource(fmode.actionName);
}}
className={classnames('button-toggle', {
'button-toggle--active': fileSelectSource === fmode.actionName,
})}
/>
))}
</div>
</div>
{fileSelectSource === SOURCE_SELECT && (
<Button
button="secondary"
label={__('Check for Replays')}
disabled={isCheckingLivestreams}
icon={ICONS.REFRESH}
onClick={() =>
checkLivestreams(channelId, channelSignature.signature, channelSignature.signing_ts)
}
/>
)}
</div>
</fieldset-section>
)}
{fileSelectSource === SOURCE_UPLOAD && showFileUpload && (
<>
<FileSelector
label={__('Video file')}
disabled={disabled}
currentPath={currentFile}
onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
accept="video/mp4,video/x-m4v,video/*"
placeholder={__('Select video file to upload')}
/>
{getUploadMessage()}
</>
)}
{fileSelectSource === SOURCE_SELECT && showFileUpload && hasLivestreamData && !isCheckingLivestreams && (
<>
<fieldset-section>
<label>{__('Select Replay')}</label>
<div className="table__wrapper">
<table className="table table--livestream-data">
<tbody>
{livestreamData.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE).map((item, i) => (
<tr
onClick={() => setSelectedFileIndex((currentPage - 1) * PAGE_SIZE + i)}
key={item.id}
className={classnames('livestream__data-row', {
'livestream__data-row--selected': selectedFileIndex === (currentPage - 1) * PAGE_SIZE + i,
})}
>
<td>
<FormField
type="radio"
checked={selectedFileIndex === (currentPage - 1) * PAGE_SIZE + i}
label={null}
onClick={() => setSelectedFileIndex((currentPage - 1) * PAGE_SIZE + i)}
className="livestream__data-row-radio"
/>
</td>
<td>
<div className="livestream_thumb_container">
{item.data.thumbnails.slice(0, 3).map((thumb) => (
<img key={thumb} className="livestream___thumb" src={thumb} />
))}
</div>
</td>
<td>
{`${Math.floor(item.data.fileDuration / 60)} ${
Math.floor(item.data.fileDuration / 60) > 1 ? __('minutes') : __('minute')
}`}
<div className="table__item-label">
{`${moment(item.data.uploadedAt).from(moment())}`}
</div>
</td>
<td>
<CopyableText
primaryButton
copyable={normalizeUrlForProtocol(item.data.fileLocation)}
snackMessage={__('Url copied.')}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</fieldset-section>
<fieldset-group class="fieldset-group--smushed fieldgroup--paginate">
<fieldset-section>
<ReactPaginate
pageCount={totalPages}
pageRangeDisplayed={2}
previousLabel=""
nextLabel=""
activeClassName="pagination__item--selected"
pageClassName="pagination__item"
previousClassName="pagination__item pagination__item--previous"
nextClassName="pagination__item pagination__item--next"
breakClassName="pagination__item pagination__item--break"
marginPagesDisplayed={2}
onPageChange={(e) => handlePaginateReplays(e.selected + 1)}
forcePage={currentPage - 1}
initialPage={currentPage - 1}
containerClassName="pagination"
/>
</fieldset-section>
</fieldset-group>
</>
)}
{fileSelectSource === SOURCE_SELECT && showFileUpload && !hasLivestreamData && !isCheckingLivestreams && (
<div className="main--empty empty">
<Empty text={__('No replays found.')} />
</div>
)}
{fileSelectSource === SOURCE_SELECT && showFileUpload && isCheckingLivestreams && (
<div className="main--empty empty">
<Spinner small />
</div>
)}
</>
{/* @endif */}
{/* @if TARGET='app' */}
{showFileUpload && (
<FileSelector <FileSelector
label={__('File')} label={__('File')}
disabled={disabled} disabled={disabled}
currentPath={currentFile} currentPath={currentFile}
onFileChosen={handleFileChange} onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
accept="video/mp4,video/x-m4v,video/*"
placeholder={__('Select video file to upload')}
/> />
)} )}
{showFileUpload && (
{isPublishPost && (
<PostEditor
label={__('Post --[noun, markdown post tab button]--')}
uri={uri}
disabled={disabled}
fileMimeType={fileMimeType}
setPrevFileText={setPrevFileText}
setCurrentFileType={setCurrentFileType}
/>
)}
{isPublishFile && getMessage()}
{/* @if TARGET='app' */}
{isPublishFile && (
<FormField <FormField
type="checkbox" type="checkbox"
checked={userOptimize} checked={userOptimize}
@ -387,7 +631,7 @@ function PublishFile(props: Props) {
name="optimize" name="optimize"
/> />
)} )}
{isPublishFile && !ffmpegAvail && ( {showFileUpload && !ffmpegAvail && (
<p className="help"> <p className="help">
<I18nMessage <I18nMessage
tokens={{ tokens={{
@ -398,7 +642,7 @@ function PublishFile(props: Props) {
</I18nMessage> </I18nMessage>
</p> </p>
)} )}
{isPublishFile && Boolean(size) && ffmpegAvail && optimize && isVid && ( {showFileUpload && Boolean(size) && ffmpegAvail && optimize && isVid && (
<p className="help"> <p className="help">
<I18nMessage <I18nMessage
tokens={{ tokens={{
@ -412,6 +656,16 @@ function PublishFile(props: Props) {
</p> </p>
)} )}
{/* @endif */} {/* @endif */}
{isPublishPost && (
<PostEditor
label={__('Post --[noun, markdown post tab button]--')}
uri={uri}
disabled={disabled}
fileMimeType={fileMimeType}
setPrevFileText={setPrevFileText}
setCurrentFileType={setCurrentFileType}
/>
)}
</React.Fragment> </React.Fragment>
} }
/> />

View file

@ -12,7 +12,11 @@ import {
doPrepareEdit, doPrepareEdit,
doCheckPublishNameAvailability, doCheckPublishNameAvailability,
SETTINGS, SETTINGS,
selectMyChannelClaims,
makeSelectClaimIsStreamPlaceholder,
makeSelectPublishFormValue,
} from 'lbry-redux'; } from 'lbry-redux';
import * as RENDER_MODES from 'constants/file_render_modes';
import { doPublishDesktop } from 'redux/actions/publish'; import { doPublishDesktop } from 'redux/actions/publish';
import { selectUnclaimedRewardValue } from 'redux/selectors/rewards'; import { selectUnclaimedRewardValue } from 'redux/selectors/rewards';
import { import {
@ -22,27 +26,40 @@ import {
selectActiveChannelStakedLevel, selectActiveChannelStakedLevel,
} from 'redux/selectors/app'; } from 'redux/selectors/app';
import { makeSelectClientSetting } from 'redux/selectors/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import PublishPage from './view'; import PublishPage from './view';
import { selectUser } from 'redux/selectors/user'; import { selectUser } from 'redux/selectors/user';
const select = (state) => ({ const select = (state) => {
const myClaimForUri = selectMyClaimForUri(state);
const permanentUrl = (myClaimForUri && myClaimForUri.permanent_url) || '';
const isPostClaim = makeSelectFileRenderModeForUri(permanentUrl)(state) === RENDER_MODES.MARKDOWN;
return {
...selectPublishFormValues(state), ...selectPublishFormValues(state),
user: selectUser(state), user: selectUser(state),
// The winning claim for a short lbry uri // The winning claim for a short lbry uri
amountNeededForTakeover: selectTakeOverAmount(state), amountNeededForTakeover: selectTakeOverAmount(state),
isLivestreamClaim: makeSelectClaimIsStreamPlaceholder(permanentUrl)(state),
isPostClaim,
permanentUrl,
// My previously published claims under this short lbry uri // My previously published claims under this short lbry uri
myClaimForUri: selectMyClaimForUri(state), myClaimForUri,
// If I clicked the "edit" button, have I changed the uri? // If I clicked the "edit" button, have I changed the uri?
// Need this to make it easier to find the source on previously published content // Need this to make it easier to find the source on previously published content
isStillEditing: selectIsStillEditing(state), isStillEditing: selectIsStillEditing(state),
filePath: makeSelectPublishFormValue('filePath')(state),
remoteUrl: makeSelectPublishFormValue('remoteFileUrl')(state),
isResolvingUri: selectIsResolvingPublishUris(state), isResolvingUri: selectIsResolvingPublishUris(state),
totalRewardValue: selectUnclaimedRewardValue(state), totalRewardValue: selectUnclaimedRewardValue(state),
modal: selectModal(state), modal: selectModal(state),
enablePublishPreview: makeSelectClientSetting(SETTINGS.ENABLE_PUBLISH_PREVIEW)(state), enablePublishPreview: makeSelectClientSetting(SETTINGS.ENABLE_PUBLISH_PREVIEW)(state),
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim: selectActiveChannelClaim(state),
myChannels: selectMyChannelClaims(state),
incognito: selectIncognito(state), incognito: selectIncognito(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state), activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
}); };
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
updatePublishForm: (value) => dispatch(doUpdatePublishForm(value)), updatePublishForm: (value) => dispatch(doUpdatePublishForm(value)),

View file

@ -9,8 +9,8 @@
*/ */
import { SITE_NAME, ENABLE_NO_SOURCE_CLAIMS, SIMPLE_SITE, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config'; import { SITE_NAME, ENABLE_NO_SOURCE_CLAIMS, SIMPLE_SITE, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config';
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { buildURI, isURIValid, isNameValid, THUMBNAIL_STATUSES } from 'lbry-redux'; import { buildURI, isURIValid, isNameValid, THUMBNAIL_STATUSES, Lbry } from 'lbry-redux';
import Button from 'component/button'; import Button from 'component/button';
import ChannelSelect from 'component/channelSelector'; import ChannelSelect from 'component/channelSelector';
import classnames from 'classnames'; import classnames from 'classnames';
@ -27,6 +27,8 @@ import I18nMessage from 'component/i18nMessage';
import * as PUBLISH_MODES from 'constants/publish_types'; import * as PUBLISH_MODES from 'constants/publish_types';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import { toHex } from 'util/hex';
import { BITWAVE_REPLAY_API } from 'constants/livestream';
// @if TARGET='app' // @if TARGET='app'
import fs from 'fs'; import fs from 'fs';
@ -82,6 +84,10 @@ type Props = {
incognito: boolean, incognito: boolean,
user: ?User, user: ?User,
activeChannelStakedLevel: number, activeChannelStakedLevel: number,
isLivestreamClaim: boolean,
isPostClaim: boolean,
permanentUrl: ?string,
remoteUrl: ?string,
}; };
function PublishForm(props: Props) { function PublishForm(props: Props) {
@ -114,28 +120,69 @@ function PublishForm(props: Props) {
incognito, incognito,
user, user,
activeChannelStakedLevel, activeChannelStakedLevel,
isLivestreamClaim,
isPostClaim,
permanentUrl,
remoteUrl,
} = props; } = props;
const { replace, location } = useHistory(); const { replace, location } = useHistory();
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
const uploadType = urlParams.get('type'); const TYPE_PARAM = 'type';
const livestreamEnabled = const uploadType = urlParams.get(TYPE_PARAM);
const enableLivestream =
ENABLE_NO_SOURCE_CLAIMS && ENABLE_NO_SOURCE_CLAIMS &&
user && user &&
!user.odysee_live_disabled && !user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled); (activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled);
// $FlowFixMe // $FlowFixMe
const MODES = livestreamEnabled const AVAILABLE_MODES = Object.values(PUBLISH_MODES).filter((mode) => {
? Object.values(PUBLISH_MODES) if (editingURI) {
: Object.values(PUBLISH_MODES).filter((mode) => mode !== PUBLISH_MODES.LIVESTREAM); if (isPostClaim) {
return mode === PUBLISH_MODES.POST;
} else if (isLivestreamClaim) {
return mode === PUBLISH_MODES.LIVESTREAM && enableLivestream;
} else {
return mode === PUBLISH_MODES.FILE;
}
} else {
if (mode === PUBLISH_MODES.LIVESTREAM) {
return enableLivestream;
}
return true;
}
});
const MODE_TO_I18N_STR = { const MODE_TO_I18N_STR = {
[PUBLISH_MODES.FILE]: SIMPLE_SITE ? 'Video' : 'File', [PUBLISH_MODES.FILE]: SIMPLE_SITE ? 'Video' : 'File',
[PUBLISH_MODES.POST]: 'Post --[noun, markdown post tab button]--', [PUBLISH_MODES.POST]: 'Post --[noun, markdown post tab button]--',
[PUBLISH_MODES.LIVESTREAM]: 'Livestream --[noun, livestream tab button]--', [PUBLISH_MODES.LIVESTREAM]: 'Livestream --[noun, livestream tab button]--',
}; };
// Component state
const [mode, setMode] = React.useState(uploadType || PUBLISH_MODES.FILE); const [mode, setMode] = React.useState(uploadType || PUBLISH_MODES.FILE);
const [isCheckingLivestreams, setCheckingLivestreams] = React.useState(false);
let customSubtitle;
if (mode === PUBLISH_MODES.LIVESTREAM || isLivestreamClaim) {
if (isLivestreamClaim) {
customSubtitle = __('Update your livestream');
} else {
customSubtitle = __('Prepare an upcoming livestream');
}
} else if (mode === PUBLISH_MODES.POST || isPostClaim) {
if (isPostClaim) {
customSubtitle = __('Edit your post');
} else {
customSubtitle = __('Craft an epic post clearly explaining... whatever.');
}
} else {
if (editingURI) {
customSubtitle = __('Update your video');
} else {
customSubtitle = __('Upload that unlabeled video you found behind the TV in 1991');
}
}
const [autoSwitchMode, setAutoSwitchMode] = React.useState(true); const [autoSwitchMode, setAutoSwitchMode] = React.useState(true);
// Used to check if the url name has changed: // Used to check if the url name has changed:
@ -145,20 +192,27 @@ function PublishForm(props: Props) {
const [fileEdited, setFileEdited] = React.useState(false); const [fileEdited, setFileEdited] = React.useState(false);
const [prevFileText, setPrevFileText] = React.useState(''); const [prevFileText, setPrevFileText] = React.useState('');
const [waitForFile, setWaitForFile] = useState(false);
const [livestreamData, setLivestreamData] = React.useState([]);
const [signedMessage, setSignedMessage] = React.useState({ signature: undefined, signing_ts: undefined });
const signedMessageStr = JSON.stringify(signedMessage);
const TAGS_LIMIT = 5; const TAGS_LIMIT = 5;
const fileFormDisabled = mode === PUBLISH_MODES.FILE && !filePath; const fileFormDisabled = mode === PUBLISH_MODES.FILE && !filePath;
const emptyPostError = mode === PUBLISH_MODES.POST && (!fileText || fileText.trim() === ''); const emptyPostError = mode === PUBLISH_MODES.POST && (!fileText || fileText.trim() === '');
const formDisabled = (fileFormDisabled && !editingURI) || emptyPostError || publishing; const formDisabled = (fileFormDisabled && !editingURI) || emptyPostError || publishing;
const isInProgress = filePath || editingURI || name || title; const isInProgress = filePath || editingURI || name || title;
const activeChannelName = activeChannelClaim && activeChannelClaim.name; const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelClaimStr = JSON.stringify(activeChannelClaim);
// Editing content info // Editing content info
const uri = myClaimForUri ? myClaimForUri.permanent_url : undefined;
const fileMimeType = const fileMimeType =
myClaimForUri && myClaimForUri.value && myClaimForUri.value.source myClaimForUri && myClaimForUri.value && myClaimForUri.value.source
? myClaimForUri.value.source.media_type ? myClaimForUri.value.source.media_type
: undefined; : undefined;
const claimChannelId = myClaimForUri && myClaimForUri.signing_channel && myClaimForUri.signing_channel.claim_id;
const nameEdited = isStillEditing && name !== prevName; const nameEdited = isStillEditing && name !== prevName;
const waitingForFile = waitForFile && !remoteUrl && !filePath;
// If they are editing, they don't need a new file chosen // If they are editing, they don't need a new file chosen
const formValidLessFile = const formValidLessFile =
name && name &&
@ -173,11 +227,33 @@ function PublishForm(props: Props) {
const formValid = isOverwritingExistingClaim const formValid = isOverwritingExistingClaim
? false ? false
: editingURI && !filePath : editingURI && !filePath // if we're editing we don't need a file
? isStillEditing && formValidLessFile ? isStillEditing && formValidLessFile && !waitingForFile
: formValidLessFile; : formValidLessFile;
const [previewing, setPreviewing] = React.useState(false); const [previewing, setPreviewing] = React.useState(false);
React.useEffect(() => {
if (activeChannelClaimStr) {
const channelClaim = JSON.parse(activeChannelClaimStr);
const message = 'get-claim-id-replays';
setSignedMessage({ signature: null, signing_ts: null });
// ensure we have a channel
if (channelClaim.claim_id) {
Lbry.channel_sign({
channel_id: channelClaim.claim_id,
hexdata: toHex(message),
})
.then((data) => {
setSignedMessage(data);
})
.catch((error) => {
setSignedMessage({ signature: null, signing_ts: null });
});
}
}
}, [activeChannelClaimStr, setSignedMessage]);
useEffect(() => { useEffect(() => {
if (!modal) { if (!modal) {
setTimeout(() => { setTimeout(() => {
@ -186,12 +262,37 @@ function PublishForm(props: Props) {
} }
}, [modal]); }, [modal]);
const isLivestream = mode === PUBLISH_MODES.LIVESTREAM; // move this to lbryinc OR to a file under ui, and/or provide a standardized livestreaming config.
function fetchLivestreams(channelId, signature, timestamp) {
setCheckingLivestreams(true);
fetch(`${BITWAVE_REPLAY_API}/${channelId}?signature=${signature || ''}&signing_ts=${timestamp || ''}`) // claimChannelId
.then((res) => res.json())
.then((res) => {
if (!res || !res.data) {
setLivestreamData([]);
}
setLivestreamData(res.data);
setCheckingLivestreams(false);
})
.catch((e) => {
setLivestreamData([]);
setCheckingLivestreams(false);
});
}
useEffect(() => {
const signedMessage = JSON.parse(signedMessageStr);
if (claimChannelId && isLivestreamClaim && signedMessage.signature) {
fetchLivestreams(claimChannelId, signedMessage.signature, signedMessage.signing_ts);
}
}, [claimChannelId, isLivestreamClaim, signedMessageStr]);
const isLivestreamMode = mode === PUBLISH_MODES.LIVESTREAM;
let submitLabel; let submitLabel;
if (publishing) { if (publishing) {
if (isStillEditing) { if (isStillEditing) {
submitLabel = __('Saving...'); submitLabel = __('Saving...');
} else if (isLivestream) { } else if (isLivestreamMode) {
submitLabel = __('Creating...'); submitLabel = __('Creating...');
} else { } else {
submitLabel = __('Uploading...'); submitLabel = __('Uploading...');
@ -201,7 +302,7 @@ function PublishForm(props: Props) {
} else { } else {
if (isStillEditing) { if (isStillEditing) {
submitLabel = __('Save'); submitLabel = __('Save');
} else if (isLivestream) { } else if (isLivestreamMode) {
submitLabel = __('Create'); submitLabel = __('Create');
} else { } else {
submitLabel = __('Upload'); submitLabel = __('Upload');
@ -221,7 +322,7 @@ function PublishForm(props: Props) {
} }
}, [thumbnail, resetThumbnailStatus]); }, [thumbnail, resetThumbnailStatus]);
// Save current name of the editing claim // Save previous name of the editing claim
useEffect(() => { useEffect(() => {
if (isStillEditing && (!prevName || !prevName.trim() === '')) { if (isStillEditing && (!prevName || !prevName.trim() === '')) {
if (name !== prevName) { if (name !== prevName) {
@ -263,17 +364,18 @@ function PublishForm(props: Props) {
} }
}, [name, activeChannelName, resolveUri, updatePublishForm, checkAvailability]); }, [name, activeChannelName, resolveUri, updatePublishForm, checkAvailability]);
// because publish editingUri is channel_short/claim_long and we don't have that, resolve it.
useEffect(() => { useEffect(() => {
// because editingURI is lbry://channel_short/claim_long and that particular shape won't map to the claimId yet
if (editingURI) { if (editingURI) {
resolveUri(editingURI); resolveUri(editingURI);
} }
}, [editingURI, resolveUri]); }, [editingURI, resolveUri]);
// set isMarkdownPost in publish form if so, also update isLivestreamPublish
useEffect(() => { useEffect(() => {
updatePublishForm({ updatePublishForm({
isMarkdownPost: mode === PUBLISH_MODES.POST, isMarkdownPost: mode === PUBLISH_MODES.POST,
isLivestreamPublish: isLivestream, isLivestreamPublish: mode === PUBLISH_MODES.LIVESTREAM,
}); });
}, [mode, updatePublishForm]); }, [mode, updatePublishForm]);
@ -282,14 +384,15 @@ function PublishForm(props: Props) {
updatePublishForm({ channel: undefined }); updatePublishForm({ channel: undefined });
// Anonymous livestreams aren't supported // Anonymous livestreams aren't supported
if (isLivestream) { if (isLivestreamMode) {
setMode(PUBLISH_MODES.FILE); setMode(PUBLISH_MODES.FILE);
} }
} else if (activeChannelName) { } else if (activeChannelName) {
updatePublishForm({ channel: activeChannelName }); updatePublishForm({ channel: activeChannelName });
} }
}, [activeChannelName, incognito, updatePublishForm]); }, [activeChannelName, incognito, updatePublishForm, isLivestreamMode]);
// set mode based on urlParams 'type'
useEffect(() => { useEffect(() => {
const _uploadType = uploadType && uploadType.toLowerCase(); const _uploadType = uploadType && uploadType.toLowerCase();
@ -310,19 +413,24 @@ function PublishForm(props: Props) {
return; return;
} }
// LiveStream publish // LiveStream publish
if (_uploadType === PUBLISH_MODES.LIVESTREAM.toLowerCase() && livestreamEnabled) { if (_uploadType === PUBLISH_MODES.LIVESTREAM.toLowerCase()) {
if (enableLivestream) {
setMode(PUBLISH_MODES.LIVESTREAM); setMode(PUBLISH_MODES.LIVESTREAM);
} else {
setMode(PUBLISH_MODES.FILE);
}
return; return;
} }
// Default to standard file publish // Default to standard file publish
setMode(PUBLISH_MODES.FILE); setMode(PUBLISH_MODES.FILE);
}, [uploadType, livestreamEnabled]); }, [uploadType, enableLivestream]);
// if we have a type urlparam, update it? necessary?
useEffect(() => { useEffect(() => {
if (!uploadType) return; if (!uploadType) return;
const newParams = new URLSearchParams(); const newParams = new URLSearchParams();
newParams.set('type', mode.toLowerCase()); newParams.set(TYPE_PARAM, mode.toLowerCase());
replace({ search: newParams.toString() }); replace({ search: newParams.toString() });
}, [mode, uploadType]); }, [mode, uploadType]);
@ -391,7 +499,7 @@ function PublishForm(props: Props) {
} }
} }
// Publish file // Publish file
if (mode === PUBLISH_MODES.FILE || isLivestream) { if (mode === PUBLISH_MODES.FILE || isLivestreamMode) {
runPublish = true; runPublish = true;
} }
@ -428,19 +536,26 @@ function PublishForm(props: Props) {
// Editing claim uri // Editing claim uri
return ( return (
<div className="card-stack"> <div className="card-stack">
<ChannelSelect hideAnon={isLivestream} disabled={disabled} /> <ChannelSelect hideAnon={isLivestreamMode} disabled={disabled} />
<PublishFile <PublishFile
uri={uri} uri={permanentUrl}
mode={mode} mode={mode}
fileMimeType={fileMimeType} fileMimeType={fileMimeType}
disabled={disabled || publishing} disabled={disabled || publishing}
inProgress={isInProgress} inProgress={isInProgress}
setPublishMode={setMode} setPublishMode={setMode}
setPrevFileText={setPrevFileText} setPrevFileText={setPrevFileText}
livestreamData={livestreamData}
subtitle={customSubtitle}
setWaitForFile={setWaitForFile}
isCheckingLivestreams={isCheckingLivestreams}
checkLivestreams={fetchLivestreams}
channelId={claimChannelId}
channelSignature={signedMessage}
header={ header={
<> <>
{MODES.map((modeName) => ( {AVAILABLE_MODES.map((modeName) => (
<Button <Button
key={String(modeName)} key={String(modeName)}
icon={modeName} icon={modeName}
@ -460,7 +575,7 @@ function PublishForm(props: Props) {
{!publishing && ( {!publishing && (
<div className={classnames({ 'card--disabled': formDisabled })}> <div className={classnames({ 'card--disabled': formDisabled })}>
{mode === PUBLISH_MODES.FILE && <PublishDescription disabled={formDisabled} />} {mode === PUBLISH_MODES.FILE && <PublishDescription disabled={formDisabled} />}
<Card actions={<SelectThumbnail />} /> <Card actions={<SelectThumbnail livestreamdData={livestreamData} />} />
<TagsSelect <TagsSelect
suggestMature={!SIMPLE_SITE} suggestMature={!SIMPLE_SITE}
disableAutoFocus disableAutoFocus
@ -489,7 +604,7 @@ function PublishForm(props: Props) {
/> />
<PublishBid disabled={isStillEditing || formDisabled} /> <PublishBid disabled={isStillEditing || formDisabled} />
{!isLivestream && <PublishPrice disabled={formDisabled} />} {!isLivestreamMode && <PublishPrice disabled={formDisabled} />}
<PublishAdditionalOptions disabled={formDisabled} /> <PublishAdditionalOptions disabled={formDisabled} />
</div> </div>
)} )}
@ -511,7 +626,7 @@ function PublishForm(props: Props) {
</div> </div>
<p className="help"> <p className="help">
{!formDisabled && !formValid ? ( {!formDisabled && !formValid ? (
<PublishFormErrors mode={mode} /> <PublishFormErrors mode={mode} waitForFile={waitingForFile} />
) : ( ) : (
<I18nMessage <I18nMessage
tokens={{ tokens={{

View file

@ -12,14 +12,26 @@ type Props = {
filePath: ?string, filePath: ?string,
isStillEditing: boolean, isStillEditing: boolean,
uploadThumbnailStatus: string, uploadThumbnailStatus: string,
waitForFile: boolean,
}; };
function PublishFormErrors(props: Props) { function PublishFormErrors(props: Props) {
const { name, title, bid, bidError, editingURI, filePath, isStillEditing, uploadThumbnailStatus } = props; const {
name,
title,
bid,
bidError,
editingURI,
filePath,
isStillEditing,
uploadThumbnailStatus,
waitForFile,
} = props;
// These are extra help // These are extra help
// If there is an error it will be presented as an inline error as well // If there is an error it will be presented as an inline error as well
return ( return (
<div className="error__text"> <div className="error__text">
{waitForFile && <div>{__('Choose a replay file, or select None')}</div>}
{!title && <div>{__('A title is required')}</div>} {!title && <div>{__('A title is required')}</div>}
{!name && <div>{__('A URL is required')}</div>} {!name && <div>{__('A URL is required')}</div>}
{!isNameValid(name, false) && INVALID_NAME_ERROR} {!isNameValid(name, false) && INVALID_NAME_ERROR}

View file

@ -1,2 +1,3 @@
export const BITWAVE_EMBED_URL = 'https://bitwave.tv/odysee'; export const BITWAVE_EMBED_URL = 'https://bitwave.tv/odysee';
export const BITWAVE_API = 'https://api.bitwave.tv/v1/odysee/live'; export const BITWAVE_LIVE_API = 'https://api.bitwave.tv/v1/odysee/live';
export const BITWAVE_REPLAY_API = 'https://api.bitwave.tv/v1/replays/odysee';

View file

@ -6,22 +6,29 @@ import {
selectPublishFormValues, selectPublishFormValues,
selectIsStillEditing, selectIsStillEditing,
selectMyChannelClaims, selectMyChannelClaims,
makeSelectClaimIsStreamPlaceholder,
SETTINGS, SETTINGS,
} from 'lbry-redux'; } from 'lbry-redux';
import { selectFfmpegStatus, makeSelectClientSetting } from 'redux/selectors/settings'; import { selectFfmpegStatus, makeSelectClientSetting } from 'redux/selectors/settings';
import { doPublishDesktop } from 'redux/actions/publish'; import { doPublishDesktop } from 'redux/actions/publish';
import { doSetClientSetting } from 'redux/actions/settings'; import { doSetClientSetting } from 'redux/actions/settings';
const select = (state) => ({ const select = (state, props) => {
const editingUri = makeSelectPublishFormValue('editingURI')(state);
return {
...selectPublishFormValues(state), ...selectPublishFormValues(state),
myChannels: selectMyChannelClaims(state), myChannels: selectMyChannelClaims(state),
isVid: makeSelectPublishFormValue('fileVid')(state), isVid: makeSelectPublishFormValue('fileVid')(state),
publishSuccess: makeSelectPublishFormValue('publishSuccess')(state), publishSuccess: makeSelectPublishFormValue('publishSuccess')(state),
publishing: makeSelectPublishFormValue('publishing')(state), publishing: makeSelectPublishFormValue('publishing')(state),
remoteFile: makeSelectPublishFormValue('remoteFileUrl')(state),
isStillEditing: selectIsStillEditing(state), isStillEditing: selectIsStillEditing(state),
ffmpegStatus: selectFfmpegStatus(state), ffmpegStatus: selectFfmpegStatus(state),
enablePublishPreview: makeSelectClientSetting(SETTINGS.ENABLE_PUBLISH_PREVIEW)(state), enablePublishPreview: makeSelectClientSetting(SETTINGS.ENABLE_PUBLISH_PREVIEW)(state),
}); isLivestreamClaim: makeSelectClaimIsStreamPlaceholder(editingUri)(state),
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
publish: (filePath, preview) => dispatch(doPublishDesktop(filePath, preview)), publish: (filePath, preview) => dispatch(doPublishDesktop(filePath, preview)),

View file

@ -43,6 +43,8 @@ type Props = {
myChannels: ?Array<ChannelClaim>, myChannels: ?Array<ChannelClaim>,
publishSuccess: boolean, publishSuccess: boolean,
publishing: boolean, publishing: boolean,
isLivestreamClaim: boolean,
remoteFile: string,
}; };
// class ModalPublishPreview extends React.PureComponent<Props> { // class ModalPublishPreview extends React.PureComponent<Props> {
@ -74,10 +76,14 @@ const ModalPublishPreview = (props: Props) => {
publishing, publishing,
publish, publish,
closeModal, closeModal,
isLivestreamClaim,
remoteFile,
} = props; } = props;
const livestream = const livestream =
(uri && isLivestreamClaim) ||
// $FlowFixMe // $FlowFixMe
previewResponse.outputs[0] && previewResponse.outputs[0].value && !previewResponse.outputs[0].value.source; (previewResponse.outputs[0] && previewResponse.outputs[0].value && !previewResponse.outputs[0].value.source);
// leave the confirm modal up if we're not going straight to upload/reflecting // leave the confirm modal up if we're not going straight to upload/reflecting
// @if TARGET='web' // @if TARGET='web'
React.useEffect(() => { React.useEffect(() => {
@ -122,7 +128,11 @@ const ModalPublishPreview = (props: Props) => {
const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available; const isOptimizeAvail = filePath && filePath !== '' && isVid && ffmpegStatus.available;
let modalTitle; let modalTitle;
if (isStillEditing) { if (isStillEditing) {
if (livestream) {
modalTitle = __('Confirm Update');
} else {
modalTitle = __('Confirm Edit'); modalTitle = __('Confirm Edit');
}
} else if (livestream) { } else if (livestream) {
modalTitle = __('Create Livestream'); modalTitle = __('Create Livestream');
} else { } else {
@ -208,6 +218,7 @@ const ModalPublishPreview = (props: Props) => {
<table className="table table--condensed table--publish-preview"> <table className="table table--condensed table--publish-preview">
<tbody> <tbody>
{!livestream && !isMarkdownPost && createRow(__('File'), getFilePathName(filePath))} {!livestream && !isMarkdownPost && createRow(__('File'), getFilePathName(filePath))}
{livestream && remoteFile && createRow(__('Replay'), __('Remote File Selected'))}
{isOptimizeAvail && createRow(__('Transcode'), optimize ? __('Yes') : __('No'))} {isOptimizeAvail && createRow(__('Transcode'), optimize ? __('Yes') : __('No'))}
{createRow(__('Title'), title)} {createRow(__('Title'), title)}
{createRow(__('Description'), descriptionValue)} {createRow(__('Description'), descriptionValue)}

View file

@ -1,5 +1,5 @@
// @flow // @flow
import { BITWAVE_API } from 'constants/livestream'; import { BITWAVE_LIVE_API } from 'constants/livestream';
import React from 'react'; import React from 'react';
import Page from 'component/page'; import Page from 'component/page';
import LivestreamLayout from 'component/livestreamLayout'; import LivestreamLayout from 'component/livestreamLayout';
@ -56,7 +56,7 @@ export default function LivestreamPage(props: Props) {
let interval; let interval;
function checkIsLive() { function checkIsLive() {
// $FlowFixMe Bitwave's API can handle garbage // $FlowFixMe Bitwave's API can handle garbage
fetch(`${BITWAVE_API}/${livestreamChannelId}`) fetch(`${BITWAVE_LIVE_API}/${livestreamChannelId}`)
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
if (!res || !res.data) { if (!res || !res.data) {

View file

@ -55,9 +55,11 @@ export default function LivestreamSetupPage(props: Props) {
const helpText = ( const helpText = (
<div className="section__subtitle"> <div className="section__subtitle">
<p> <p>
{__(`Create a Livestream by first submitting your Livestream details and waiting for approval confirmation.`)}{' '}
{__( {__(
`The livestream will not be visible on your channel until you are live, but you can share the URL in advance.` `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.`
)}{' '} )}{' '}
{__( {__(
`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.`
@ -73,25 +75,18 @@ export default function LivestreamSetupPage(props: Props) {
<li>{__(`Tune: Zerolatency`)}</li> <li>{__(`Tune: Zerolatency`)}</li>
</ul> </ul>
<p> <p>
{__( {__(`If using other streaming software, make sure the bitrate is below 4500 kbps or the stream will not work.`)}
`If using other livestreaming software, make sure the bitrate is below 5000 kbps or the stream will not work.`
)}
</p> </p>
<p> <p>
{__( {__(`After your stream:
`Please note: You'll need to record your own stream through your software if you plan to share it afterward. You can also delete it if you prefer not to upload the copy.` Click 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).`)}
)}
</p> </p>
<p>{__(`Click Save, then confirm, and you are done!`)}</p>
<p> <p>
{__( {__(
`In the near future, this manual step will be removed and you will be able to share the stream right after its finished without needing to record it yourself.` `Note: If you don't plan on publishing your replay, you'll want to delete your livestream and then start with a fresh one next time.`
)} )}
</p> </p>
<p>
{__(`After your livestream:
Click the Publish Replay button. This will allow you to edit details before sharing on Odysee. Be sure to select the saved mp4 file you recorded.`)}
</p>
<p>{__(`Click Save and you are done!`)}</p>
</div> </div>
); );

View file

@ -29,9 +29,10 @@ export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispat
const noFileParam = !filePath || filePath === NO_FILE; const noFileParam = !filePath || filePath === NO_FILE;
const state = getState(); const state = getState();
const editingUri = makeSelectPublishFormValue('editingURI')(state) || ''; const editingUri = makeSelectPublishFormValue('editingURI')(state) || '';
const remoteUrl = makeSelectPublishFormValue('remoteFileUrl')(state);
const claim = makeSelectClaimForUri(editingUri)(state) || {}; const claim = makeSelectClaimForUri(editingUri)(state) || {};
const hasSourceFile = claim.value && claim.value.source; const hasSourceFile = claim.value && claim.value.source;
const redirectToLivestream = noFileParam && !hasSourceFile; const redirectToLivestream = noFileParam && !hasSourceFile && !remoteUrl;
const publishSuccess = (successResponse, lbryFirstError) => { const publishSuccess = (successResponse, lbryFirstError) => {
const state = getState(); const state = getState();

View file

@ -28,7 +28,6 @@
@import 'component/form-field'; @import 'component/form-field';
@import 'component/header'; @import 'component/header';
@import 'component/icon'; @import 'component/icon';
@import 'component/livestream';
@import 'component/main'; @import 'component/main';
@import 'component/markdown-editor'; @import 'component/markdown-editor';
@import 'component/markdown-preview'; @import 'component/markdown-preview';
@ -54,6 +53,7 @@
@import 'component/status-bar'; @import 'component/status-bar';
@import 'component/syntax-highlighter'; @import 'component/syntax-highlighter';
@import 'component/table'; @import 'component/table';
@import 'component/livestream';
@import 'component/tabs'; @import 'component/tabs';
@import 'component/tooltip'; @import 'component/tooltip';
@import 'component/txo-list'; @import 'component/txo-list';

View file

@ -205,3 +205,84 @@
.livestream__publish-intro { .livestream__publish-intro {
margin-top: var(--spacing-l); margin-top: var(--spacing-l);
} }
.table--livestream-data {
td:nth-of-type(1) {
max-width: 4rem;
}
td:nth-of-type(2) {
min-width: 8.5rem;
}
td:nth-of-type(3) {
width: 4rem;
min-width: 9rem;
}
td:nth-of-type(4) {
display: none;
}
@media (min-width: $breakpoint-small) {
td:nth-of-type(1) {
max-width: 4rem;
}
td:nth-of-type(2) {
width: 40%;
}
td:nth-of-type(3) {
width: 5rem;
}
td:nth-of-type(4) {
width: 100%;
display: table-cell;
}
}
}
.livestream_thumb_container {
height: 4rem;
width: 100%;
display: flex;
flex-direction: row;
overflow: hidden;
}
.livestream___thumb {
padding: 0 var(--spacing-xxs);
object-fit: cover;
}
.livestream__data-row {
cursor: pointer;
.radio {
cursor: pointer;
}
&:nth-child(n) {
&.livestream__data-row--selected {
background-color: var(--color-input-toggle-bg-hover);
}
}
td {
padding-right: var(--spacing-m);
@media (max-width: $breakpoint-small) {
padding: var(--spacing-xs);
}
}
&:hover {
td {
.radio {
label::before {
cursor: pointer;
background-color: var(--color-input-toggle-bg-hover);
}
}
label {
cursor: pointer;
}
}
input {
cursor: pointer;
background-color: var(--color-input-bg-selected);
}
}
}

View file

@ -40,12 +40,28 @@
td { td {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
th {
font-size: var(--font-xsmall);
color: var(--color-text-help);
padding-bottom: var(--spacing-xxxs);
@media (max-width: $breakpoint-small) {
padding-left: var(--spacing-xs) !important;
padding-right: var(--spacing-xs) !important;
}
}
} }
th { th {
text-align: left; text-align: left;
} }
td {
@media (max-width: $breakpoint-small) {
font-size: var(--font-small);
}
}
.table--fixed { .table--fixed {
table-layout: fixed; table-layout: fixed;
} }

View file

@ -172,6 +172,10 @@
} }
} }
.section__actions--align-bottom {
align-items: flex-end;
}
.section__actions--no-margin { .section__actions--no-margin {
@extend .section__actions; @extend .section__actions;
margin-top: 0; margin-top: 0;

View file

@ -42,8 +42,10 @@ export default function apiPublishCallViaWeb(
if (fileField) { if (fileField) {
body.append('file', fileField); body.append('file', fileField);
params.file_path = '__POST_FILE__'; params.file_path = '__POST_FILE__';
delete params['remote_url'];
} else if (remoteUrl) { } else if (remoteUrl) {
body.append('remote_url', remoteUrl); body.append('remote_url', remoteUrl);
delete params['remote_url'];
} }
const jsonPayload = JSON.stringify({ const jsonPayload = JSON.stringify({