Merge pull request #1576 from lbryio/file-preview

Add FileRender component
This commit is contained in:
Sean Yesmunt 2018-07-10 10:38:23 -04:00 committed by GitHub
commit e317f573bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 348 additions and 58 deletions

View file

@ -48,8 +48,9 @@
"formik": "^0.10.4",
"hast-util-sanitize": "^1.1.2",
"keytar": "^4.2.1",
"lbry-redux": "lbryio/lbry-redux#201d78b68a329065ee5d2a03bfb1607ea0666588",
"lbry-redux": "lbryio/lbry-redux#341db02c090e0121ff7f5d2e74fa35b06213d858",
"localforage": "^1.7.1",
"mime": "^2.3.1",
"mixpanel-browser": "^2.17.1",
"moment": "^2.22.0",
"qrcode.react": "^0.8.0",
@ -76,6 +77,7 @@
"semver": "^5.3.0",
"shapeshift.io": "^1.3.1",
"source-map-support": "^0.5.4",
"stream-to-blob-url": "^2.1.1",
"tree-kill": "^1.1.0",
"y18n": "^4.0.0"
},

View file

@ -14,7 +14,7 @@ export default appState => {
defaultHeight: height,
});
let windowConfiguration = {
const windowConfiguration = {
backgroundColor: '#44b098',
minWidth: 950,
minHeight: 600,
@ -26,17 +26,13 @@ export default appState => {
// If state is undefined, create window as maximized.
width: windowState.width === undefined ? width : windowState.width,
height: windowState.height === undefined ? height : windowState.height,
};
// Disable renderer process's webSecurity on development to enable CORS.
windowConfiguration = isDev
? {
...windowConfiguration,
webPreferences: {
webSecurity: false,
},
}
: windowConfiguration;
webPreferences: {
// Disable renderer process's webSecurity on development to enable CORS.
webSecurity: !isDev,
plugins: true,
},
};
const rendererURL = isDev
? `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`

View file

@ -3,8 +3,8 @@ import React from 'react';
import Spinner from 'component/spinner';
type Props = {
spinner: boolean,
status: string,
spinner: boolean,
};
class LoadingScreen extends React.PureComponent<Props> {
@ -17,8 +17,7 @@ class LoadingScreen extends React.PureComponent<Props> {
return (
<div className="content__loading">
{spinner && <Spinner light />}
<span className="content__loading-text">{status}</span>
{status && <span className="content__loading-text">{status}</span>}
</div>
);
}

View file

@ -0,0 +1,16 @@
import { connect } from 'react-redux';
import { THEME } from 'constants/settings';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import FileRender from './view';
const select = (state, props) => ({
currentTheme: makeSelectClientSetting(THEME)(state),
});
const perform = dispatch => ({});
export default connect(
select,
perform
)(FileRender);

View file

@ -0,0 +1,47 @@
// @flow
import React from 'react';
import LoadingScreen from 'component/common/loading-screen';
import PdfViewer from 'component/viewers/pdfViewer';
type Props = {
mediaType: string,
source: {
filePath: string,
fileType: string,
downloadPath: string,
},
currentTheme: string,
};
class FileRender extends React.PureComponent<Props> {
renderViewer() {
const { source, mediaType, currentTheme } = this.props;
const viewerProps = { source, theme: currentTheme };
// Supported mediaTypes
const mediaTypes = {
// '3D-file': <ThreeViewer {...viewerProps}/>,
// Add routes to viewer...
};
// Supported fileType
const fileTypes = {
pdf: <PdfViewer {...viewerProps} />,
// Add routes to viewer...
};
const { fileType } = source;
const viewer = mediaType && source && (mediaTypes[mediaType] || fileTypes[fileType]);
const unsupportedMessage = __("Sorry, looks like we can't preview this file.");
const unsupported = <LoadingScreen status={unsupportedMessage} spinner={false} />;
// Return viewer
return viewer || unsupported;
}
render() {
return <div className="file-render">{this.renderViewer()}</div>;
}
}
export default FileRender;

View file

@ -17,7 +17,7 @@ import {
import { makeSelectClientSetting, selectShowNsfw } from 'redux/selectors/settings';
import { selectMediaPaused, makeSelectMediaPositionForUri } from 'redux/selectors/media';
import { selectPlayingUri } from 'redux/selectors/content';
import Video from './view';
import FileViewer from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
@ -45,4 +45,7 @@ const perform = dispatch => ({
savePosition: (claimId, position) => dispatch(savePosition(claimId, position)),
});
export default connect(select, perform)(Video);
export default connect(
select,
perform
)(FileViewer);

View file

@ -1,6 +1,7 @@
// @flow
import React from 'react';
import Button from 'component/button';
import * as icons from 'constants/icons';
type Props = {
play: () => void,
@ -14,8 +15,8 @@ class VideoPlayButton extends React.PureComponent<Props> {
const { fileInfo, mediaType, isLoading, play } = this.props;
const disabled = isLoading || fileInfo === undefined;
const doesPlayback = ['audio', 'video'].indexOf(mediaType) !== -1;
const icon = doesPlayback ? 'Play' : 'Folder';
const label = doesPlayback ? 'Play' : 'View';
const icon = doesPlayback ? icons.PLAY : icons.EYE;
const label = doesPlayback ? __('Play') : __('View');
return <Button button="primary" disabled={disabled} label={label} icon={icon} onClick={play} />;
}

View file

@ -1,13 +1,17 @@
/* eslint-disable */
import React from 'react';
import { remote } from 'electron';
import Thumbnail from 'component/common/thumbnail';
import player from 'render-media';
import fs from 'fs';
import LoadingScreen from './loading-screen';
import path from 'path';
import player from 'render-media';
import toBlobURL from 'stream-to-blob-url';
import FileRender from 'component/fileRender';
import Thumbnail from 'component/common/thumbnail';
import LoadingScreen from 'component/common/loading-screen';
class VideoPlayer extends React.PureComponent {
static MP3_CONTENT_TYPES = ['audio/mpeg3', 'audio/mpeg'];
static FILE_MEDIA_TYPES = ['e-book', 'comic-book', 'document', '3D-file'];
constructor(props) {
super(props);
@ -16,6 +20,7 @@ class VideoPlayer extends React.PureComponent {
hasMetadata: false,
startedPlaying: false,
unplayable: false,
fileSource: null,
};
this.togglePlayListener = this.togglePlay.bind(this);
@ -29,7 +34,7 @@ class VideoPlayer extends React.PureComponent {
componentDidMount() {
const container = this.media;
const { contentType, changeVolume, volume, position, claim } = this.props;
const { downloadCompleted, contentType, changeVolume, volume, position, claim } = this.props;
const loadedMetadata = () => {
this.setState({ hasMetadata: true, startedPlaying: true });
@ -51,7 +56,13 @@ class VideoPlayer extends React.PureComponent {
// use renderAudio override for mp3
if (VideoPlayer.MP3_CONTENT_TYPES.indexOf(contentType) > -1) {
this.renderAudio(container, null, false);
} else {
}
// Render custom viewer: FileRender
else if (this.fileType()) {
downloadCompleted && this.renderFile();
}
// Render default viewer: render-media (video, audio, img, iframe)
else {
player.append(
this.file(),
container,
@ -89,7 +100,7 @@ class VideoPlayer extends React.PureComponent {
componentDidUpdate() {
const { contentType, downloadCompleted } = this.props;
const { startedPlaying } = this.state;
const { startedPlaying, fileSource } = this.state;
if (this.playableType() && !startedPlaying && downloadCompleted) {
const container = this.media.children[0];
@ -102,6 +113,8 @@ class VideoPlayer extends React.PureComponent {
controls: true,
});
}
} else if (this.fileType() && !fileSource && downloadCompleted) {
this.renderFile();
}
}
@ -159,6 +172,40 @@ class VideoPlayer extends React.PureComponent {
return ['audio', 'video'].indexOf(mediaType) !== -1;
}
supportedType() {
// Files supported by render-media
const { contentType, mediaType } = this.props;
return Object.values(player.mime).indexOf(contentType) !== -1;
}
fileType() {
// This files are supported using a custom viewer
const { mediaType } = this.props;
return VideoPlayer.FILE_MEDIA_TYPES.indexOf(mediaType) > -1;
}
renderFile() {
// This is what render-media does with unplayable files
const { filename, downloadPath, contentType, mediaType } = this.props;
toBlobURL(fs.createReadStream(downloadPath), contentType, (err, url) => {
if (err) {
this.setState({ unsupported: true });
return false;
}
// File to render
const fileSource = {
downloadPath,
filePath: url,
fileType: path.extname(filename).substring(1),
};
// Update state
this.setState({ fileSource });
});
}
renderAudio(container, autoplay) {
if (container.firstChild) {
container.firstChild.remove();
@ -173,25 +220,61 @@ class VideoPlayer extends React.PureComponent {
container.appendChild(audio);
}
showLoadingScreen(isFileType, isPlayableType) {
const { mediaType } = this.props;
const { hasMetadata, unplayable, unsupported, fileSource } = this.state;
const loader = {
isLoading: false,
loadingStatus: null,
};
// Loading message
const noFileMessage = __('Waiting for blob.');
const noMetadataMessage = __('Waiting for metadata.');
// Error message
const unplayableMessage = __("Sorry, looks like we can't play this file.");
const unsupportedMessage = __("Sorry, looks like we can't preview this file.");
// Files
const isLoadingFile = !fileSource && isFileType;
const isUnsupported =
(mediaType === 'application' || !this.supportedType()) && !isFileType && !isPlayableType;
// Media (audio, video)
const isUnplayable = isPlayableType && unplayable;
const isLoadingMetadata = isPlayableType && (!hasMetadata && !unplayable);
// Show loading message
if (isLoadingFile || isLoadingMetadata) {
loader.loadingStatus = isFileType ? noFileMessage : noMetadataMessage;
loader.isLoading = true;
// Show unsupported error message
} else if (isUnsupported || isUnplayable) {
loader.loadingStatus = isUnsupported ? unsupportedMessage : unplayableMessage;
}
return loader;
}
render() {
const { mediaType, poster } = this.props;
const { hasMetadata, unplayable } = this.state;
const noMetadataMessage = 'Waiting for metadata.';
const unplayableMessage = "Sorry, looks like we can't play this file.";
const hideMedia = this.playableType() && !hasMetadata && !unplayable;
const { mediaType } = this.props;
const { fileSource } = this.state;
const isFileType = this.fileType();
const isFileReady = fileSource && isFileType;
const isPlayableType = this.playableType();
const { isLoading, loadingStatus } = this.showLoadingScreen(isFileType, isPlayableType);
return (
<React.Fragment>
{['audio', 'application'].indexOf(mediaType) !== -1 &&
(!this.playableType() || hasMetadata) &&
!unplayable && <Thumbnail src={poster} />}
{this.playableType() &&
!hasMetadata &&
!unplayable && <LoadingScreen status={noMetadataMessage} />}
{unplayable && <LoadingScreen status={unplayableMessage} spinner={false} />}
{loadingStatus && <LoadingScreen status={loadingStatus} spinner={isLoading} />}
{isFileReady && <FileRender source={fileSource} mediaType={mediaType} />}
<div
className={'content__view--container'}
style={{ opacity: hideMedia ? 0 : 1 }}
style={{ opacity: isLoading ? 0 : 1 }}
ref={container => {
this.media = container;
}}

View file

@ -1,11 +1,10 @@
// @flow
import React from 'react';
import { Lbry } from 'lbry-redux';
import classnames from 'classnames';
import type { Claim } from 'types/claim';
import VideoPlayer from './internal/player';
import VideoPlayButton from './internal/play-button';
import LoadingScreen from './internal/loading-screen';
import LoadingScreen from 'component/common/loading-screen';
import Player from './internal/player';
import PlayButton from './internal/play-button';
const SPACE_BAR_KEYCODE = 32;
@ -40,9 +39,10 @@ type Props = {
obscureNsfw: boolean,
play: string => void,
searchBarFocused: boolean,
mediaType: string,
};
class Video extends React.PureComponent<Props> {
class FileViewer extends React.PureComponent<Props> {
constructor() {
super();
@ -123,12 +123,12 @@ class Video extends React.PureComponent<Props> {
mediaPosition,
className,
obscureNsfw,
mediaType,
} = this.props;
const isPlaying = playingUri === uri;
const isReadyToPlay = fileInfo && fileInfo.written_bytes > 0;
const shouldObscureNsfw = obscureNsfw && metadata && metadata.nsfw;
const mediaType = Lbry.getMediaType(contentType, fileInfo && fileInfo.file_name);
let loadStatusMessage = '';
@ -156,7 +156,7 @@ class Video extends React.PureComponent<Props> {
<LoadingScreen status={loadStatusMessage} />
</div>
) : (
<VideoPlayer
<Player
filename={fileInfo.file_name}
poster={poster}
downloadPath={fileInfo.download_path}
@ -183,7 +183,7 @@ class Video extends React.PureComponent<Props> {
className={layoverClass}
style={layoverStyle}
>
<VideoPlayButton
<PlayButton
play={e => {
e.stopPropagation();
this.playContent();
@ -200,4 +200,4 @@ class Video extends React.PureComponent<Props> {
}
}
export default Video;
export default FileViewer;

View file

@ -0,0 +1,37 @@
// @flow
import React from 'react';
type Props = {
source: {
fileType: string,
filePath: string,
downloadPath: string,
},
};
class PdfViewer extends React.PureComponent<Props> {
constructor(props) {
super(props);
this.viewer = React.createRef();
}
// TODO: Enable context-menu
stopContextMenu = event => {
event.preventDefault();
event.stopPropagation();
};
render() {
const { source } = this.props;
return (
<div className="file-render__viewer" onContextMenu={this.stopContextMenu}>
<webview
ref={this.viewer}
src={`chrome://pdf-viewer/index.html?src=file://${source.downloadPath}`}
/>
</div>
);
}
}
export default PdfViewer;

View file

@ -28,3 +28,5 @@ export const CHECK_SIMPLE = 'Check';
export const GLOBE = 'Globe';
export const EXTERNAL_LINK = 'ExternalLink';
export const GIFT = 'Gift';
export const EYE = 'Eye';
export const PLAY = 'Play';

View file

@ -1,7 +1,7 @@
// @flow
import * as React from 'react';
import { Lbry, buildURI, normalizeURI, MODALS } from 'lbry-redux';
import Video from 'component/video';
import { buildURI, normalizeURI, MODALS } from 'lbry-redux';
import FileViewer from 'component/fileViewer';
import Thumbnail from 'component/common/thumbnail';
import FilePrice from 'component/filePrice';
import FileDetails from 'component/fileDetails';
@ -14,7 +14,6 @@ import Button from 'component/button';
import SubscribeButton from 'component/subscribeButton';
import ViewOnWebButton from 'component/viewOnWebButton';
import Page from 'component/page';
import player from 'render-media';
import * as settings from 'constants/settings';
import type { Claim } from 'types/claim';
import type { Subscription } from 'types/subscription';
@ -22,6 +21,7 @@ import FileDownloadLink from 'component/fileDownloadLink';
import classnames from 'classnames';
import { FormField, FormRow } from 'component/common/form';
import ToolTip from 'component/common/tooltip';
import getMediaType from 'util/getMediaType';
type Props = {
claim: Claim,
@ -29,6 +29,7 @@ type Props = {
metadata: {
title: string,
thumbnail: string,
file_name: string,
nsfw: boolean,
},
contentType: string,
@ -49,6 +50,18 @@ type Props = {
};
class FilePage extends React.Component<Props> {
static PLAYABLE_MEDIA_TYPES = ['audio', 'video'];
static PREVIEW_MEDIA_TYPES = [
'text',
'model',
'image',
'3D-file',
'document',
// Bypass unplayable files
// TODO: Find a better way to detect supported types
'application',
];
constructor(props: Props) {
super(props);
@ -108,15 +121,19 @@ class FilePage extends React.Component<Props> {
navigate,
autoplay,
costInfo,
fileInfo,
} = this.props;
// File info
const { title, thumbnail } = metadata;
const { height, channel_name: channelName, value } = claim;
const { PLAYABLE_MEDIA_TYPES, PREVIEW_MEDIA_TYPES } = FilePage;
const isRewardContent = rewardedContentClaimIds.includes(claim.claim_id);
const shouldObscureThumbnail = obscureNsfw && metadata.nsfw;
const { height, channel_name: channelName, value } = claim;
const mediaType = Lbry.getMediaType(contentType);
const isPlayable = Object.values(player.mime).includes(contentType) || mediaType === 'audio';
const fileName = fileInfo ? fileInfo.file_name : null;
const mediaType = getMediaType(contentType, fileName);
const showFile =
PLAYABLE_MEDIA_TYPES.includes(mediaType) || PREVIEW_MEDIA_TYPES.includes(mediaType);
const channelClaimId =
value && value.publisherSignature && value.publisherSignature.certificateId;
let subscriptionUri;
@ -150,8 +167,10 @@ class FilePage extends React.Component<Props> {
</section>
) : (
<section className="card">
{isPlayable && <Video className="content__embedded" uri={uri} />}
{!isPlayable &&
{showFile && (
<FileViewer className="content__embedded" uri={uri} mediaType={mediaType} />
)}
{!showFile &&
(thumbnail ? (
<Thumbnail shouldObscure={shouldObscureThumbnail} src={thumbnail} />
) : (
@ -160,7 +179,9 @@ class FilePage extends React.Component<Props> {
'content__empty--nsfw': shouldObscureThumbnail,
})}
>
<div className="card__media-text">{__('This content is not playable.')}</div>
<div className="card__media-text">
{__("Sorry, looks like we can't preview this file.")}
</div>
</div>
))}
<div className="card__content">

View file

@ -23,5 +23,6 @@
@import 'component/_spinner.scss';
@import 'component/_nav.scss';
@import 'component/_file-list.scss';
@import 'component/_file-render.scss';
@import 'component/_search.scss';
@import 'component/_toggle.scss';

View file

@ -96,6 +96,25 @@
}
}
.file-render {
width: 100%;
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
overflow: hidden;
.file-render__viewer {
width: 100%;
height: 100%;
background: black;
}
}
img {
max-height: 100%;
max-width: 100%;

View file

@ -0,0 +1,25 @@
.file-render {
width: 100%;
height: 100%;
margin: auto;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
overflow: hidden;
.file-render__viewer {
margin: 0;
width: 100%;
height: 100%;
background: black;
iframe,
webview {
width: 100%;
height: 100%;
}
}
}

View file

@ -0,0 +1,32 @@
import mime from 'mime';
const formats = [
[/\.(mp4|m4v|webm|flv|f4v|ogv)$/i, 'video'],
[/\.(mp3|m4a|aac|wav|flac|ogg|opus)$/i, 'audio'],
[/\.(html|htm|xml|pdf|odf|doc|docx|md|markdown|txt|epub|org)$/i, 'document'],
[/\.(stl|obj|fbx|gcode)$/i, '3D-file'],
];
export default function getMediaType(contentType, fileName) {
const extName = mime.getExtension(contentType);
const fileExt = extName ? `.${extName}` : null;
const testString = fileName || fileExt;
// Get mediaType from file extension
if (testString) {
const res = formats.reduce((ret, testpair) => {
const [regex, mediaType] = testpair;
return regex.test(ret) ? mediaType : ret;
}, testString);
if (res !== testString) return res;
}
// Get mediaType from contentType
if (contentType) {
return /^[^/]+/.exec(contentType)[0];
}
return 'unknown';
}

View file

@ -5564,9 +5564,9 @@ lazy-val@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.3.tgz#bb97b200ef00801d94c317e29dc6ed39e31c5edc"
lbry-redux@lbryio/lbry-redux#201d78b68a329065ee5d2a03bfb1607ea0666588:
lbry-redux@lbryio/lbry-redux#341db02c090e0121ff7f5d2e74fa35b06213d858:
version "0.0.1"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/201d78b68a329065ee5d2a03bfb1607ea0666588"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/341db02c090e0121ff7f5d2e74fa35b06213d858"
dependencies:
proxy-polyfill "0.1.6"
reselect "^3.0.0"
@ -8726,6 +8726,12 @@ stream-to-blob-url@^2.0.0:
dependencies:
stream-to-blob "^1.0.0"
stream-to-blob-url@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/stream-to-blob-url/-/stream-to-blob-url-2.1.1.tgz#e1ac97f86ca8e9f512329a48e7830ce9a50beef2"
dependencies:
stream-to-blob "^1.0.0"
stream-to-blob@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/stream-to-blob/-/stream-to-blob-1.0.0.tgz#9f7a1ada39e16ea282ebb7e4cda307edabde658d"