lbry-desktop/ui/component/publishFile/view.jsx

675 lines
24 KiB
React
Raw Normal View History

// @flow
import { SITE_NAME, WEB_PUBLISH_SIZE_LIMIT_GB, SIMPLE_SITE } from 'config';
2020-08-05 19:19:15 +02:00
import type { Node } from 'react';
2019-09-27 20:56:15 +02:00
import * as ICONS from 'constants/icons';
import React, { useState, useEffect } from 'react';
import { regexInvalidURI } from 'lbry-redux';
2020-07-29 22:30:26 +02:00
import PostEditor from 'component/postEditor';
import FileSelector from 'component/common/file-selector';
import Button from 'component/button';
2019-09-27 20:56:15 +02:00
import Card from 'component/common/card';
import { FormField } from 'component/common/form';
import Spinner from 'component/spinner';
import I18nMessage from 'component/i18nMessage';
import usePersistedState from 'effects/use-persisted-state';
import * as PUBLISH_MODES from 'constants/publish_types';
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 = {
uri: ?string,
mode: ?string,
name: ?string,
title: ?string,
filePath: string | WebFile,
fileMimeType: ?string,
isStillEditing: boolean,
balance: number,
updatePublishForm: ({}) => void,
2019-09-27 20:56:15 +02:00
disabled: boolean,
publishing: boolean,
showToast: (string) => void,
inProgress: boolean,
clearPublish: () => void,
ffmpegStatus: any,
optimize: boolean,
size: number,
duration: number,
isVid: boolean,
subtitle: string,
setPublishMode: (string) => void,
setPrevFileText: (string) => void,
2020-08-05 19:19:15 +02:00
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) {
const {
uri,
mode,
name,
title,
balance,
filePath,
fileMimeType,
isStillEditing,
updatePublishForm,
disabled,
publishing,
inProgress,
clearPublish,
optimize,
ffmpegStatus = {},
size,
duration,
isVid,
setPublishMode,
setPrevFileText,
header,
livestreamData,
isLivestreamClaim,
subtitle,
checkLivestreams,
channelId,
channelSignature,
isCheckingLivestreams,
setWaitForFile,
} = 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 [oversized, setOversized] = useState(false);
const [currentFile, setCurrentFile] = useState(null);
const [currentFileType, setCurrentFileType] = useState(null);
const [optimizeAvail, setOptimizeAvail] = useState(false);
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
2020-10-05 20:24:57 +02:00
const UPLOAD_SIZE_MESSAGE = __(
'%SITE_NAME% uploads are limited to %limit% GB. Download the app for unrestricted publishing.',
{ SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_GB_STR }
2020-10-05 20:24:57 +02:00
);
const fileSelectorModes = [
{ 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
useEffect(() => {
2020-07-29 22:30:26 +02:00
if (mode === PUBLISH_MODES.POST) {
if (currentFileType !== 'text/markdown' && !isStillEditing) {
2020-07-30 00:55:48 +02:00
updatePublishForm({ filePath: '' });
}
} else if (mode === PUBLISH_MODES.LIVESTREAM) {
updatePublishForm({ filePath: '' });
}
}, [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(() => {
if (!filePath || filePath === '') {
setCurrentFile('');
setOversized(false);
updateFileInfo(0, 0, false);
} else if (typeof filePath !== 'string') {
// Update currentFile file
if (filePath.name !== currentFile && filePath.path !== currentFile) {
handleFileChange(filePath);
}
}
}, [filePath, currentFile, handleFileChange, updateFileInfo]);
useEffect(() => {
const isOptimizeAvail = currentFile && currentFile !== '' && isVid && ffmpegAvail;
const finalOptimizeState = isOptimizeAvail && userOptimize;
setOptimizeAvail(isOptimizeAvail);
updatePublishForm({ optimize: finalOptimizeState });
}, [currentFile, filePath, isVid, ffmpegAvail, userOptimize, updatePublishForm]);
function updateFileInfo(duration, size, isvid) {
updatePublishForm({ fileDur: duration, fileSize: size, fileVid: isvid });
}
function handlePaginateReplays(page) {
setCurrentPage(page);
}
function getBitrate(size, duration) {
const s = Number(size);
const d = Number(duration);
if (s && d) {
return (s * 8) / d;
} else {
return 0;
}
}
function getTimeForMB(s) {
if (s < MINUTES_THRESHOLD) {
return Math.floor(secondsToProcess);
} else if (s >= MINUTES_THRESHOLD && s < HOURS_THRESHOLD) {
return Math.floor(secondsToProcess / 60);
} else {
return Math.floor(secondsToProcess / 60 / 60);
}
}
function getUnitsForMB(s) {
if (s < MINUTES_THRESHOLD) {
if (secondsToProcess > 1) return __('seconds');
return __('second');
} else if (s >= MINUTES_THRESHOLD && s < HOURS_THRESHOLD) {
if (Math.floor(secondsToProcess / 60) > 1) return __('minutes');
return __('minute');
} else {
if (Math.floor(secondsToProcess / 3600) > 1) return __('hours');
return __('hour');
}
}
function getUploadMessage() {
// @if TARGET='web'
if (oversized) {
return (
<p className="help--error">
2020-10-05 20:24:57 +02:00
{UPLOAD_SIZE_MESSAGE}{' '}
2020-07-23 19:02:07 +02:00
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p>
);
}
// @endif
if (isVid && duration && getBitrate(size, duration) > RECOMMENDED_BITRATE) {
return (
<p className="help--warning">
2020-05-12 20:57:02 +02:00
{__('Your video has a bitrate over 5 Mbps. We suggest transcoding to provide viewers the best experience.')}{' '}
2020-07-23 19:02:07 +02:00
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p>
);
}
if (isVid && !duration) {
return (
<p className="help--warning">
{__(
2020-05-12 20:57:02 +02:00
'Your video may not be the best format. Use MP4s in H264/AAC format and a friendly bitrate (under 5 Mbps) and resolution (720p) for more reliable streaming.'
)}{' '}
2020-07-23 19:02:07 +02:00
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p>
);
}
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 (
<p className="help">
{__("If you don't choose a file, the file from your existing claim %name% will be used", { name: name })}
</p>
);
}
// @if TARGET='web'
if (!isStillEditing) {
return (
<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.',
{ SITE_NAME, limit: TV_PUBLISH_SIZE_LIMIT_GB_STR }
)}{' '}
2020-07-23 19:02:07 +02:00
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p>
);
}
// @endif
// @if TARGET='app'
if (!isStillEditing) {
return (
<p className="help">
{__(
2020-05-12 20:57:02 +02:00
'For video content, use MP4s in H264/AAC format and a friendly bitrate (under 5 Mbps) and resolution (720p) for more reliable streaming.'
)}{' '}
2020-07-23 19:02:07 +02:00
<Button button="link" label={__('Upload Guide')} href="https://lbry.com/faq/video-publishing-guide" />
</p>
);
}
// @endif
}
2020-07-30 00:55:48 +02:00
function parseName(newName) {
let INVALID_URI_CHARS = new RegExp(regexInvalidURI, 'gu');
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);
}
2020-07-30 06:03:56 +02:00
function handleTitleChange(event) {
const title = event.target.value;
2020-07-30 00:55:48 +02:00
// Update title
2020-07-30 06:03:56 +02:00
updatePublishForm({ title });
2020-07-30 00:55:48 +02:00
}
2020-08-11 04:08:03 +02:00
function handleFileReaderLoaded(event: ProgressEvent) {
// See: https://github.com/facebook/flow/issues/3470
if (event.target instanceof FileReader) {
const text = event.target.result;
updatePublishForm({ fileText: text });
setPublishMode(PUBLISH_MODES.POST);
}
}
function handleFileChange(file: WebFile) {
2019-12-09 19:51:00 +01:00
const { showToast } = props;
window.URL = window.URL || window.webkitURL;
setOversized(false);
// select file, start to select a new one, then cancel
2020-05-28 16:45:56 +02:00
if (!file) {
if (isStillEditing) {
updatePublishForm({ filePath: '' });
} else {
updatePublishForm({ filePath: '', name: '' });
}
return;
}
// if video, extract duration so we can warn about bitrateif (typeof file !== 'string') {
2020-05-28 16:45:56 +02:00
const contentType = file.type && file.type.split('/');
const isVideo = contentType && contentType[0] === 'video';
const isMp4 = contentType && contentType[1] === 'mp4';
2020-08-24 22:45:08 +02:00
let isTextPost = false;
2020-08-24 22:45:08 +02:00
if (contentType && contentType[0] === 'text') {
isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown';
setCurrentFileType(contentType);
} else if (file.name) {
// If user's machine is missign a valid content type registration
// for markdown content: text/markdown, file extension will be used instead
const extension = file.name.split('.').pop();
2020-08-24 22:45:08 +02:00
isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension);
}
if (isVideo) {
if (isMp4) {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
updateFileInfo(video.duration, file.size, isVideo);
window.URL.revokeObjectURL(video.src);
};
video.onerror = () => {
updateFileInfo(0, file.size, isVideo);
};
video.src = window.URL.createObjectURL(file);
} else {
updateFileInfo(0, file.size, isVideo);
}
} else {
updateFileInfo(0, file.size, isVideo);
}
2020-08-24 22:45:08 +02:00
if (isTextPost) {
// Create reader
const reader = new FileReader();
// Handler for file reader
2020-08-11 04:08:03 +02:00
reader.addEventListener('load', handleFileReaderLoaded);
// Read file contents
reader.readAsText(file);
setCurrentFileType('text/markdown');
} else {
setPublishMode(PUBLISH_MODES.FILE);
}
// @if TARGET='web'
// we only need to enforce file sizes on 'web'
if (file.size && Number(file.size) > TV_PUBLISH_SIZE_LIMIT_BYTES) {
setOversized(true);
showToast(__(UPLOAD_SIZE_MESSAGE));
updatePublishForm({ filePath: '', name: '' });
return;
}
// @endif
2019-12-09 19:51:00 +01:00
const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = {
// if electron, we'll set filePath to the path string because SDK is handling publishing.
// File.path will be undefined from web due to browser security, so it will default to the File Object.
filePath: file.path || file,
};
2019-11-01 18:27:01 +01:00
// Strip off extention and replace invalid characters
let fileName = name || (file.name && file.name.substr(0, file.name.lastIndexOf('.'))) || '';
2020-07-30 00:55:48 +02:00
if (!isStillEditing) {
2020-07-30 00:55:48 +02:00
publishFormParams.name = parseName(fileName);
}
2020-07-30 06:03:56 +02:00
// File path is not supported on web for security reasons so we use the name instead.
setCurrentFile(file.path || file.name);
updatePublishForm(publishFormParams);
}
const showFileUpload = mode === PUBLISH_MODES.FILE || (mode === PUBLISH_MODES.LIVESTREAM && hasLivestreamData);
2020-07-29 22:30:26 +02:00
const isPublishPost = mode === PUBLISH_MODES.POST;
return (
2019-09-27 20:56:15 +02:00
<Card
className={disabled || balance === 0 ? 'card--disabled' : ''}
title={
2020-08-05 19:19:15 +02:00
<div>
{header} {/* display mode buttons from parent */}
{publishing && <Spinner type={'small'} />}
2020-08-05 19:19:15 +02:00
{inProgress && (
<div>
2021-03-26 22:03:52 +01:00
<Button button="close" label={__('New')} icon={ICONS.REFRESH} onClick={clearPublish} />
2020-08-05 19:19:15 +02:00
</div>
)}
</div>
}
subtitle={subtitle || (isStillEditing && __('You are currently editing your upload.'))}
2019-09-27 20:56:15 +02:00
actions={
<React.Fragment>
<PublishName uri={uri} />
<FormField
type="text"
name="content_title"
label={__('Title')}
placeholder={__('Descriptive titles work best')}
disabled={disabled}
value={title}
2020-07-30 00:55:48 +02:00
onChange={handleTitleChange}
/>
{/* 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
2021-04-16 04:52:41 +02:00
label={SIMPLE_SITE ? __('Video/audio file') : __('File')}
disabled={disabled}
currentPath={currentFile}
onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
2021-04-16 04:52:41 +02:00
accept={SIMPLE_SITE ? 'video/mp4,video/x-m4v,video/*,audio/*' : undefined}
placeholder={SIMPLE_SITE ? __('Select video or audio file to upload') : __('Select a 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
label={__('File')}
disabled={disabled}
currentPath={currentFile}
onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
placeholder={__('Select file to upload')}
/>
)}
{showFileUpload && (
<FormField
type="checkbox"
checked={userOptimize}
disabled={!optimizeAvail}
onChange={() => setUserOptimize(!userOptimize)}
label={__('Optimize and transcode video')}
name="optimize"
/>
)}
{showFileUpload && !ffmpegAvail && (
<p className="help">
<I18nMessage
tokens={{
settings_link: <Button button="link" navigate="/$/settings" label={__('Settings')} />,
}}
>
FFmpeg not configured. More in %settings_link%.
</I18nMessage>
</p>
)}
{showFileUpload && Boolean(size) && ffmpegAvail && optimize && isVid && (
<p className="help">
<I18nMessage
tokens={{
size: Math.ceil(sizeInMB),
processTime: getTimeForMB(sizeInMB),
units: getUnitsForMB(sizeInMB),
}}
>
2020-05-12 20:57:02 +02:00
Transcoding this %size% MB file should take under %processTime% %units%.
</I18nMessage>
</p>
)}
{/* @endif */}
{isPublishPost && (
<PostEditor
label={__('Post --[noun, markdown post tab button]--')}
uri={uri}
disabled={disabled}
fileMimeType={fileMimeType}
setPrevFileText={setPrevFileText}
setCurrentFileType={setCurrentFileType}
/>
)}
2019-09-27 20:56:15 +02:00
</React.Fragment>
}
/>
);
}
export default PublishFile;