floating player actually working

This commit is contained in:
Sean Yesmunt 2019-08-13 01:35:13 -04:00
parent 90bcde49e7
commit 2b09d56b63
39 changed files with 582 additions and 271 deletions

View file

@ -148,6 +148,7 @@
"rc-progress": "^2.0.6",
"react": "^16.8.2",
"react-dom": "^16.8.2",
"react-draggable": "^3.3.0",
"react-ga": "^2.5.7",
"react-hot-loader": "^4.11.1",
"react-modal": "^3.1.7",

View file

@ -1,6 +1,6 @@
import { hot } from 'react-hot-loader/root';
import { connect } from 'react-redux';
import { doUpdateBlockHeight, doError, doFetchTransactions } from 'lbry-redux';
import { doError, doFetchTransactions } from 'lbry-redux';
import { selectUser, doRewardList, doFetchRewardedContent, doFetchAccessToken, selectAccessToken } from 'lbryinc';
import { selectThemePath } from 'redux/selectors/settings';
import App from './view';
@ -13,7 +13,6 @@ const select = state => ({
const perform = dispatch => ({
alertError: errorList => dispatch(doError(errorList)),
updateBlockHeight: () => dispatch(doUpdateBlockHeight()),
fetchRewards: () => dispatch(doRewardList()),
fetchRewardedContent: () => dispatch(doFetchRewardedContent()),
fetchTransactions: () => dispatch(doFetchTransactions()),

View file

@ -1,7 +1,7 @@
// @flow
import React, { useEffect, useRef } from 'react';
import analytics from 'analytics';
import { Lbry } from 'lbry-redux';
import { Lbry, buildURI } from 'lbry-redux';
import Router from 'component/router/index';
import ModalRouter from 'modal/modalRouter';
import ReactModal from 'react-modal';
@ -10,6 +10,8 @@ import Header from 'component/header';
import { openContextMenu } from 'util/context-menu';
import useKonamiListener from 'util/enhanced-layout';
import Yrbl from 'component/yrbl';
import FileViewer from 'component/fileViewer';
import { withRouter } from 'react-router';
export const MAIN_WRAPPER_CLASS = 'main-wrapper';
@ -20,6 +22,7 @@ type Props = {
theme: string,
accessToken: ?string,
user: ?{ id: string, has_verified_email: boolean },
location: { pathname: string },
fetchRewards: () => void,
fetchRewardedContent: () => void,
fetchTransactions: () => void,
@ -33,6 +36,18 @@ function App(props: Props) {
const userId = user && user.id;
const hasVerifiedEmail = user && user.has_verified_email;
const { pathname } = props.location;
const urlParts = pathname.split('/');
const claimName = urlParts[1];
const claimId = urlParts[2];
// @routingfixme
// claimName and claimId come from the url `{domain}/{claimName}/{claimId}"
let uri;
try {
uri = buildURI({ contentName: claimName, claimId: claimId });
} catch (e) {}
useEffect(() => {
ReactModal.setAppElement(appRef.current);
fetchAccessToken();
@ -42,7 +57,7 @@ function App(props: Props) {
fetchRewards();
fetchTransactions();
// @endif
}, [fetchRewards, fetchRewardedContent, fetchTransactions]);
}, [fetchRewards, fetchRewardedContent, fetchTransactions, fetchAccessToken]);
useEffect(() => {
// $FlowFixMe
@ -73,9 +88,11 @@ function App(props: Props) {
</div>
<ModalRouter />
<FileViewer pageUri={uri} />
{isEnhancedLayout && <Yrbl className="yrbl--enhanced" />}
</div>
);
}
export default App;
export default withRouter(App);

View file

@ -127,7 +127,12 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
title={title}
aria-label={description || label || title}
className={combinedClassName}
onClick={onClick}
onClick={e => {
if (onClick) {
e.stopPropagation();
onClick(e);
}
}}
disabled={disabled}
type={type}
{...otherProps}

View file

@ -276,4 +276,11 @@ export const icons = {
<polyline points="17 6 23 6 23 12" />
</g>
),
[ICONS.VIEW]: buildIcon(
<g>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" y1="12" x2="3" y2="12" />
</g>
),
};

View file

@ -1,6 +1,7 @@
// @flow
import type { Node } from 'react';
import { Lbryio } from 'lbryinc';
import * as React from 'react';
import React, { Fragment } from 'react';
import Yrbl from 'component/yrbl';
import Button from 'component/button';
import { withRouter } from 'react-router';
@ -8,7 +9,7 @@ import Native from 'native';
import { Lbry } from 'lbry-redux';
type Props = {
children: React.Node,
children: Node,
history: {
replace: string => void,
},
@ -73,7 +74,7 @@ class ErrorBoundary extends React.Component<Props, State> {
type="sad"
title={__('Aw shucks!')}
subtitle={
<div>
<Fragment>
<p>
{__("There was an error. It's been reported and will be fixed")}. {__('Try')}{' '}
<Button
@ -84,7 +85,7 @@ class ErrorBoundary extends React.Component<Props, State> {
/>{' '}
{__('to fix it')}.
</p>
</div>
</Fragment>
}
/>
</div>

View file

@ -16,8 +16,9 @@ type Props = {
class FileActions extends React.PureComponent<Props> {
render() {
const { fileInfo, uri, openModal, claimIsMine, claimId } = this.props;
const showDelete = claimIsMine || (fileInfo && (fileInfo.written_bytes > 0 || fileInfo.blobs_completed === true));
const showDelete =
claimIsMine ||
(fileInfo && (fileInfo.written_bytes > 0 || fileInfo.blobs_completed === fileInfo.blobs_in_stream));
return (
<React.Fragment>
{showDelete && (

View file

@ -94,9 +94,12 @@ class FileRender extends React.PureComponent<Props> {
renderViewer() {
const { mediaType, currentTheme, claim, contentType, downloadPath, fileName, streamingUrl, uri } = this.props;
const fileType = fileName && path.extname(fileName).substring(1);
// Ideally the lbrytv api server would just replace the streaming_url returned by the sdk so we don't need this check
// https://github.com/lbryio/lbrytv/issues/51
const source = IS_WEB ? `https://api.lbry.tv/content/claims/${claim.name}/${claim.claim_id}/stream` : streamingUrl;
// Human-readable files (scripts and plain-text files)
const readableFiles = ['text', 'document', 'script'];
@ -108,17 +111,19 @@ class FileRender extends React.PureComponent<Props> {
application: <AppViewer uri={uri} />,
// @endif
video: <VideoViewer source={streamingUrl} contentType={contentType} />,
audio: <VideoViewer source={streamingUrl} contentType={contentType} />,
image: <ImageViewer source={streamingUrl} />,
video: <VideoViewer uri={uri} source={source} contentType={contentType} />,
audio: <VideoViewer uri={uri} source={source} contentType={contentType} />,
image: <ImageViewer uri={uri} source={source} />,
// Add routes to viewer...
};
// Supported fileType
const fileTypes = {
// @if TARGET='app'
pdf: <PdfViewer source={downloadPath} />,
docx: <DocxViewer source={downloadPath} />,
html: <HtmlViewer source={downloadPath} />,
// @endif
// Add routes to viewer...
};
@ -151,7 +156,7 @@ class FileRender extends React.PureComponent<Props> {
// @endif
// Message Error
const unsupportedMessage = __("We can't preview this file.");
const unsupportedMessage = __("Sorry, we can't preview this file.");
const unsupported = <LoadingScreen status={unsupportedMessage} spinner={false} />;
// Return viewer

View file

@ -1,30 +1,42 @@
import * as SETTINGS from 'constants/settings';
import { connect } from 'react-redux';
import { doPlayUri } from 'redux/actions/content';
import {
makeSelectFileInfoForUri,
makeSelectThumbnailForUri,
makeSelectStreamingUrlForUri,
makeSelectMediaTypeForUri,
makeSelectUriIsStreamable,
makeSelectTitleForUri,
} from 'lbry-redux';
import { makeSelectIsPlaying, makeSelectShouldObscurePreview } from 'redux/selectors/content';
import { makeSelectIsPlaying, makeSelectShouldObscurePreview, selectPlayingUri } from 'redux/selectors/content';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { doSetPlayingUri } from 'redux/actions/content';
import { withRouter } from 'react-router';
import FileViewer from './view';
const select = (state, props) => ({
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
mediaType: makeSelectMediaTypeForUri(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
obscurePreview: makeSelectShouldObscurePreview(props.uri)(state),
isPlaying: makeSelectIsPlaying(props.uri)(state),
streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state),
isStreamable: makeSelectUriIsStreamable(props.uri)(state),
});
const select = (state, props) => {
const uri = selectPlayingUri(state);
return {
uri,
title: makeSelectTitleForUri(uri)(state),
thumbnail: makeSelectThumbnailForUri(uri)(state),
mediaType: makeSelectMediaTypeForUri(uri)(state),
fileInfo: makeSelectFileInfoForUri(uri)(state),
obscurePreview: makeSelectShouldObscurePreview(uri)(state),
isPlaying: makeSelectIsPlaying(uri)(state),
streamingUrl: makeSelectStreamingUrlForUri(uri)(state),
isStreamable: makeSelectUriIsStreamable(uri)(state),
floatingPlayerEnabled: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state),
};
};
const perform = dispatch => ({
play: uri => dispatch(doPlayUri(uri)),
clearPlayingUri: () => dispatch(doSetPlayingUri(null)),
});
export default connect(
select,
perform
)(FileViewer);
export default withRouter(
connect(
select,
perform
)(FileViewer)
);

View file

@ -1,15 +1,17 @@
// @flow
import React, { Fragment, useEffect, useCallback } from 'react';
import * as ICONS from 'constants/icons';
import React, { useEffect } from 'react';
import Button from 'component/button';
import classnames from 'classnames';
import LoadingScreen from 'component/common/loading-screen';
import Button from 'component/button';
import FileRender from 'component/fileRender';
import isUserTyping from 'util/detect-typing';
const SPACE_BAR_KEYCODE = 32;
import UriIndicator from 'component/uriIndicator';
import usePersistedState from 'util/use-persisted-state';
import { FILE_WRAPPER_CLASS } from 'page/file/view';
import Draggable from 'react-draggable';
import Tooltip from 'component/common/tooltip';
type Props = {
play: (string, boolean) => void,
mediaType: string,
isLoading: boolean,
isPlaying: boolean,
@ -20,87 +22,122 @@ type Props = {
isStreamable: boolean,
thumbnail?: string,
streamingUrl?: string,
floatingPlayer: boolean,
pageUri: ?string,
title: ?string,
floatingPlayerEnabled: boolean,
clearPlayingUri: () => void,
};
export default function FileViewer(props: Props) {
const {
play,
mediaType,
isPlaying,
fileInfo,
uri,
obscurePreview,
insufficientCredits,
thumbnail,
streamingUrl,
isStreamable,
pageUri,
title,
clearPlayingUri,
floatingPlayerEnabled,
} = props;
const [fileViewerRect, setFileViewerRect] = usePersistedState('inline-file-viewer:rect');
const [position, setPosition] = usePersistedState('floating-file-viewer:position', {
x: -25,
y: window.innerHeight - 400,
});
const isPlayable = ['audio', 'video'].indexOf(mediaType) !== -1;
const fileStatus = fileInfo && fileInfo.status;
const isReadyToPlay = (isStreamable && streamingUrl) || (fileInfo && fileInfo.completed);
const inline = pageUri === uri;
const isReadyToPlay = (IS_WEB && isStreamable) || (isStreamable && streamingUrl) || (fileInfo && fileInfo.completed);
const loadingMessage =
!isStreamable && fileInfo && fileInfo.blobs_completed >= 1 && (!fileInfo.download_path || !fileInfo.written_bytes)
? __("It looks like you deleted or moved this file. We're rebuilding it now. It will only take a few seconds.")
: __('Loading');
// Wrap this in useCallback because we need to use it to the keyboard effect
// If we don't a new instance will be created for every render and react will think the dependencies have change, which will add/remove the listener for every render
const viewFile = useCallback(
(e: SyntheticInputEvent<*> | KeyboardEvent) => {
e.stopPropagation();
// Check for user setting here
const saveFile = !isStreamable;
play(uri, saveFile);
},
[play, uri, isStreamable]
);
function handleDrag(e, ui) {
const { x, y } = position;
const newX = x + ui.deltaX;
const newY = y + ui.deltaY;
setPosition({
x: newX,
y: newY,
});
}
useEffect(() => {
// This is just for beginning to download a file
// Play/Pause/Fullscreen will be handled by the respective viewers because not every file type should behave the same
function handleKeyDown(e: KeyboardEvent) {
if (!isUserTyping() && e.keyCode === SPACE_BAR_KEYCODE) {
e.preventDefault();
if (!isPlaying || fileStatus === 'stopped') {
viewFile(e);
}
function handleResize() {
const element = document.querySelector(`.${FILE_WRAPPER_CLASS}`);
if (!element) {
throw new Error("Can't find file viewer wrapper to attach to");
}
const rect = element.getBoundingClientRect();
// @FlowFixMe
setFileViewerRect(rect);
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isPlaying, fileStatus, viewFile]);
if (inline) {
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}
}, [setFileViewerRect, inline]);
const hidePlayer = !isPlaying || !uri || (!inline && (!floatingPlayerEnabled || !isStreamable));
if (hidePlayer) {
clearPlayingUri();
return null;
}
return (
<div
onClick={viewFile}
style={!obscurePreview && thumbnail ? { backgroundImage: `url("${thumbnail}")` } : {}}
className={classnames('video content__cover content__embedded', {
'card__media--nsfw': obscurePreview,
'card__media--disabled': !fileInfo && insufficientCredits,
})}
<Draggable
onDrag={handleDrag}
defaultPosition={position}
position={inline ? { x: 0, y: 0 } : position}
bounds="parent"
disabled={inline}
handle=".content__info"
cancel=".button"
>
{isPlaying && (
<Fragment>{isReadyToPlay ? <FileRender uri={uri} /> : <LoadingScreen status={loadingMessage} />}</Fragment>
)}
{!isPlaying && (
<Button
onClick={viewFile}
iconSize={30}
title={isPlayable ? __('Play') : __('View')}
className={classnames('button--icon', {
'button--play': isPlayable,
'button--view': !isPlayable,
<div
className={classnames('content__viewer', {
'content__viewer--floating': !inline,
})}
style={
inline && fileViewerRect
? { width: fileViewerRect.width, height: fileViewerRect.height, left: fileViewerRect.x }
: {}
}
>
<div
className={classnames('content__wrapper', {
'content__wrapper--floating': !inline,
})}
/>
)}
</div>
>
{!inline && (
<div className="content__actions">
<Tooltip label={__('View File')}>
<Button navigate={uri} icon={ICONS.VIEW} button="close" className="content__hide-viewer" />
</Tooltip>
<Tooltip label={__('Close')}>
<Button onClick={clearPlayingUri} icon={ICONS.REMOVE} button="close" className="content__hide-viewer" />
</Tooltip>
</div>
)}
{isReadyToPlay ? <FileRender uri={uri} /> : <LoadingScreen status={loadingMessage} />}
{!inline && (
<div className="content__info">
<div className="claim-preview-title" title={title || uri}>
{title || uri}
</div>
<UriIndicator link addTooltip={false} uri={uri} />
</div>
)}
</div>
</div>
</Draggable>
);
}

View file

@ -0,0 +1,37 @@
import * as SETTINGS from 'constants/settings';
import { connect } from 'react-redux';
import { doPlayUri, doSetPlayingUri } from 'redux/actions/content';
import {
makeSelectFileInfoForUri,
makeSelectThumbnailForUri,
makeSelectStreamingUrlForUri,
makeSelectMediaTypeForUri,
makeSelectUriIsStreamable,
} from 'lbry-redux';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { makeSelectIsPlaying, makeSelectShouldObscurePreview, selectPlayingUri } from 'redux/selectors/content';
import FileViewer from './view';
const select = (state, props) => ({
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
mediaType: makeSelectMediaTypeForUri(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
obscurePreview: makeSelectShouldObscurePreview(props.uri)(state),
isPlaying: makeSelectIsPlaying(props.uri)(state),
playingUri: selectPlayingUri(state),
streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state),
isStreamable: makeSelectUriIsStreamable(props.uri)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state),
});
const perform = dispatch => ({
play: uri => {
dispatch(doSetPlayingUri(uri));
dispatch(doPlayUri(uri));
},
});
export default connect(
select,
perform
)(FileViewer);

View file

@ -0,0 +1,94 @@
// @flow
// This component is entirely for triggering the start of a file view
// The actual viewer for a file exists in FileViewer
// They can't exist in one component because we need to handle/listen for the start of a new file view
// while a file is currently being viewed
import React, { useEffect, useCallback } from 'react';
import classnames from 'classnames';
import Button from 'component/button';
import isUserTyping from 'util/detect-typing';
const SPACE_BAR_KEYCODE = 32;
type Props = {
play: string => void,
mediaType: string,
isLoading: boolean,
isPlaying: boolean,
fileInfo: FileListItem,
uri: string,
obscurePreview: boolean,
insufficientCredits: boolean,
isStreamable: boolean,
thumbnail?: string,
autoplay: boolean,
};
export default function FileViewer(props: Props) {
const { play, mediaType, isPlaying, fileInfo, uri, obscurePreview, insufficientCredits, thumbnail, autoplay } = props;
const isPlayable = ['audio', 'video'].indexOf(mediaType) !== -1;
const fileStatus = fileInfo && fileInfo.status;
// Wrap this in useCallback because we need to use it to the keyboard effect
// If we don't a new instance will be created for every render and react will think the dependencies have change, which will add/remove the listener for every render
const viewFile = useCallback(
(e?: SyntheticInputEvent<*> | KeyboardEvent) => {
if (e) {
e.stopPropagation();
}
play(uri);
},
[play, uri]
);
useEffect(() => {
// This is just for beginning to download a file
// Play/Pause/Fullscreen will be handled by the respective viewers because not every file type should behave the same
function handleKeyDown(e: KeyboardEvent) {
if (!isUserTyping() && e.keyCode === SPACE_BAR_KEYCODE) {
e.preventDefault();
if (!isPlaying || fileStatus === 'stopped') {
viewFile(e);
}
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isPlaying, fileStatus, viewFile]);
useEffect(() => {
const videoOnPage = document.querySelector('video');
if (autoplay && !videoOnPage) {
viewFile();
}
}, [autoplay, viewFile]);
return (
<div
onClick={viewFile}
style={!obscurePreview && thumbnail ? { backgroundImage: `url("${thumbnail}")` } : {}}
className={classnames('content__cover', {
'card__media--nsfw': obscurePreview,
'card__media--disabled': !fileInfo && insufficientCredits,
})}
>
{!isPlaying && (
<Button
onClick={viewFile}
iconSize={30}
title={isPlayable ? __('Play') : __('View')}
className={classnames('button--icon', {
'button--play': isPlayable,
'button--view': !isPlayable,
})}
/>
)}
</div>
);
}

View file

@ -40,7 +40,10 @@ function AppViewer(props: Props) {
return (
<div className="file-render__viewer">
{!appUrl && (
<LoadingScreen status={loading ? __('Almost there') : __('Unable to view this file')} spinner={loading} />
<LoadingScreen
status={loading ? __('Almost there') : __('Unable to view this file in the app')}
spinner={loading}
/>
)}
{appUrl && (
<webview

View file

@ -35,8 +35,10 @@ class CodeViewer extends React.PureComponent<Props> {
const me = this;
const { theme, contentType } = me.props;
// Init CodeMirror
import(/* webpackChunkName: "codemirror" */
'codemirror/lib/codemirror').then(CodeMirror => {
import(
/* webpackChunkName: "codemirror" */
'codemirror/lib/codemirror'
).then(CodeMirror => {
me.codeMirror = CodeMirror.fromTextArea(me.textarea, {
// Auto detect syntax with file contentType
mode: contentType,
@ -62,7 +64,7 @@ class CodeViewer extends React.PureComponent<Props> {
render() {
const { value } = this.props;
return (
<div className="code-viewer" onContextMenu={stopContextMenu}>
<div className="file-render__content" onContextMenu={stopContextMenu}>
<textarea ref={textarea => (this.textarea = textarea)} disabled value={value} />
</div>
);

View file

@ -5,8 +5,10 @@ import LoadingScreen from 'component/common/loading-screen';
import MarkdownPreview from 'component/common/markdown-preview';
const LazyCodeViewer = React.lazy<*>(() =>
import(/* webpackChunkName: "codeViewer" */
'component/viewers/codeViewer')
import(
/* webpackChunkName: "codeViewer" */
'component/viewers/codeViewer'
)
);
type Props = {
@ -81,7 +83,7 @@ class DocumentViewer extends React.PureComponent<Props, State> {
const errorMessage = __("Sorry, looks like we can't load the document.");
return (
<div className="file-render__viewer document-viewer">
<div className="file-render__viewer--document">
{loading && !error && <LoadingScreen status={loadingMessage} spinner />}
{error && <LoadingScreen status={errorMessage} spinner={!error} />}
{isReady && <Suspense fallback={<div />}>{this.renderDocument()}</Suspense>}

View file

@ -3,7 +3,6 @@
import React from 'react';
import mammoth from 'mammoth';
import LoadingScreen from 'component/common/loading-screen';
import MarkdownPreview from 'component/common/markdown-preview';
type Props = {
source: string,
@ -59,10 +58,10 @@ class DocxViewer extends React.PureComponent<Props, State> {
const errorMessage = __("Sorry, looks like we can't load the document.");
return (
<div className="document-viewer file-render__viewer">
<div className="file-render__viewer--document">
{loading && <LoadingScreen status={loadingMessage} spinner />}
{error && <LoadingScreen status={errorMessage} spinner={false} />}
{content && <div className="document-viewer__content" dangerouslySetInnerHTML={{ __html: content }} />}
{content && <div className="file-render__content" dangerouslySetInnerHTML={{ __html: content }} />}
</div>
);
}

View file

@ -28,7 +28,7 @@ class PdfViewer extends React.PureComponent<Props> {
shell.openExternal(path);
// @endif
// @if TARGET='web'
console.error('provide stub for shell.openExternal');
console.error('provide stub for shell.openExternal'); // eslint-disable-line
// @endif
}
@ -37,7 +37,7 @@ class PdfViewer extends React.PureComponent<Props> {
// This was disabled on electron@3
// https://github.com/electron/electron/issues/12337
return (
<div className="file-render__viewer file-render--pdf" onContextMenu={stopContextMenu}>
<div className="file-render__viewer--pdf" onContextMenu={stopContextMenu}>
<p>
{__('PDF opened externally.')} <Button button="link" label={__('Click here')} onClick={this.openFile} />{' '}
{__('to open it again.')}

View file

@ -1,4 +1,5 @@
import { connect } from 'react-redux';
import { makeSelectFileInfoForUri } from 'lbry-redux';
import { doChangeVolume, doChangeMute } from 'redux/actions/app';
import { selectVolume, selectMute } from 'redux/selectors/app';
import { savePosition } from 'redux/actions/content';
@ -9,11 +10,12 @@ const select = (state, props) => ({
volume: selectVolume(state),
position: makeSelectContentPositionForUri(props.uri)(state),
muted: selectMute(state),
hasFileInfo: Boolean(makeSelectFileInfoForUri(props.uri)(state)),
});
const perform = dispatch => ({
changeVolume: volume => dispatch(doChangeVolume(volume)),
savePosition: (claimId, outpoint, position) => dispatch(savePosition(claimId, outpoint, position)),
savePosition: (uri, position) => dispatch(savePosition(uri, position)),
changeMute: muted => dispatch(doChangeMute(muted)),
});

View file

@ -1,5 +1,5 @@
// @flow
import React, { createRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import { stopContextMenu } from 'util/context-menu';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
@ -10,20 +10,20 @@ const VIDEO_JS_OPTIONS = {
autoplay: true,
controls: true,
preload: 'auto',
playbackRates: [0.5, 1, 1.25, 1.5, 2],
playbackRates: [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 2],
};
type Props = {
source: string,
contentType: string,
muted: boolean,
hasFileInfo: boolean,
};
function VideoViewer(props: Props) {
const { contentType, source } = props;
const videoRef = createRef();
const videoRef = useRef();
const [requireRedraw, setRequireRedraw] = useState(false);
// Handle any other effects separately to avoid re-mounting the video player when props change
useEffect(() => {
const videoNode = videoRef.current;
const videoJsOptions = {
@ -36,42 +36,57 @@ function VideoViewer(props: Props) {
],
};
const player = videojs(videoNode, videoJsOptions);
let player;
if (!requireRedraw) {
player = videojs(videoNode, videoJsOptions);
}
return () => {
player.dispose();
if (!player) {
return;
}
// Video.js has a player.dispose() function that is meant to cleanup a previous video
// We can't use this because it does some weird stuff to remove the video element from the page
// This makes it really hard to use because the ref we keep still thinks it's on the page
// requireRedraw just makes it so the video component is removed from the page _by react_
// Then it's set to false immediately after so we can re-mount a new player
setRequireRedraw(true);
};
}, [videoRef, source, contentType]);
}, [videoRef, source, contentType, setRequireRedraw, requireRedraw]);
useEffect(() => {
if (requireRedraw) {
setRequireRedraw(false);
}
}, [requireRedraw]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const videoNode = videoRef && videoRef.current;
if (!videoNode) return;
const videoNode = videoRef.current;
if (!isUserTyping() && e.keyCode === SPACE_BAR_KEYCODE) {
if (videoNode && !isUserTyping() && e.keyCode === SPACE_BAR_KEYCODE) {
e.preventDefault();
const isPaused = videoNode.paused;
if (isPaused) {
videoNode.play();
return;
}
videoNode.pause();
videoNode.paused ? videoNode.play() : videoNode.pause();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [videoRef]);
// include requireRedraw here so the event listener is re-added when we need to manually remove/add the video player
}, [videoRef, requireRedraw]);
return (
<div className="file-render__viewer" onContextMenu={stopContextMenu}>
<div data-vjs-player>
<video ref={videoRef} className="video-js" />
</div>
{!requireRedraw && (
<div data-vjs-player>
<video ref={videoRef} className="video-js" />
</div>
)}
</div>
);
}

View file

@ -35,7 +35,6 @@ export const WEB = 'Globe';
export const SHARE = 'Share2';
export const EXTERNAL = 'ExternalLink';
export const TIP = 'Gift';
export const VIEW = 'Eye';
export const PLAY = 'Play';
export const FACEBOOK = 'Facebook';
export const TWITTER = 'Twitter';
@ -73,3 +72,4 @@ export const TAG = 'Tag';
export const SUPPORT = 'TrendingUp';
export const BLOCK = 'Slash';
export const UNBLOCK = 'Circle';
export const VIEW = 'View';

View file

@ -19,3 +19,4 @@ export const AUTO_DOWNLOAD = 'autoDownload';
export const SUPPORT_OPTION = 'supportOption';
export const HIDE_BALANCE = 'hideBalance';
export const HIDE_SPLASH_ANIMATION = 'hideSplashAnimation';
export const FLOATING_PLAYER = 'floatingPlayer';

View file

@ -14,7 +14,10 @@ const perform = dispatch => ({
dispatch(doHideModal());
},
closeModal: () => dispatch(doHideModal()),
loadVideo: uri => dispatch(doPlayUri(uri, true)),
loadVideo: uri => {
dispatch(doSetPlayingUri(uri));
dispatch(doPlayUri(uri, true));
},
});
export default connect(

View file

@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import * as settings from 'constants/settings';
import { selectRewardContentClaimIds, selectPlayingUri } from 'redux/selectors/content';
import { selectRewardContentClaimIds } from 'redux/selectors/content';
import { doRemoveUnreadSubscription } from 'redux/actions/subscriptions';
import { doSetClientSetting } from 'redux/actions/settings';
import { doSetContentHistoryItem } from 'redux/actions/content';
@ -32,9 +32,7 @@ const select = (state, props) => ({
obscureNsfw: !selectShowMatureContent(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
rewardedContentClaimIds: selectRewardContentClaimIds(state, props),
playingUri: selectPlayingUri(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
autoplay: makeSelectClientSetting(settings.AUTOPLAY)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
channelUri: makeSelectChannelForClaimUri(props.uri, true)(state),
viewCount: makeSelectViewCountForUri(props.uri)(state),

View file

@ -3,7 +3,7 @@ import * as MODALS from 'constants/modal_types';
import * as icons from 'constants/icons';
import * as React from 'react';
import { buildURI, normalizeURI } from 'lbry-redux';
import FileViewer from 'component/fileViewer';
import FileViewerInitiator from 'component/fileViewerInitiator';
import FilePrice from 'component/filePrice';
import FileDetails from 'component/fileDetails';
import FileActions from 'component/fileActions';
@ -19,6 +19,8 @@ import CommentCreate from 'component/commentCreate';
import ClaimUri from 'component/claimUri';
import ClaimPreview from 'component/claimPreview';
export const FILE_WRAPPER_CLASS = 'grid-area--content';
type Props = {
claim: StreamClaim,
fileInfo: FileListItem,
@ -149,7 +151,7 @@ class FilePage extends React.Component<Props> {
return (
<Page className="main--file-page">
<div className="grid-area--content card">
<div className={`card ${FILE_WRAPPER_CLASS}`}>
{!fileInfo && insufficientCredits && (
<div className="media__insufficient-credits help--warning">
{__(
@ -159,7 +161,7 @@ class FilePage extends React.Component<Props> {
{__('or send more LBC to your wallet.')}
</div>
)}
<FileViewer uri={uri} insufficientCredits={insufficientCredits} />
<FileViewerInitiator uri={uri} insufficientCredits={insufficientCredits} />
</div>
<div className="columns">

View file

@ -28,6 +28,7 @@ const select = state => ({
supportOption: makeSelectClientSetting(settings.SUPPORT_OPTION)(state),
userBlockedChannelsCount: selectBlockedChannelsCount(state),
hideBalance: makeSelectClientSetting(settings.HIDE_BALANCE)(state),
floatingPlayer: makeSelectClientSetting(settings.FLOATING_PLAYER)(state),
});
const perform = dispatch => ({

View file

@ -50,7 +50,8 @@ type Props = {
supportOption: boolean,
userBlockedChannelsCount?: number,
hideBalance: boolean,
maxConnections: number,
floatingPlayer: boolean,
clearPlayingUri: () => void,
};
type State = {
@ -166,7 +167,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
supportOption,
hideBalance,
userBlockedChannelsCount,
maxConnections,
floatingPlayer,
} = this.props;
const noDaemonSettings = !daemonSettings || Object.keys(daemonSettings).length === 0;
@ -174,7 +175,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
const defaultMaxKeyFee = { currency: 'USD', amount: 50 };
const disableMaxKeyFee = !(daemonSettings && daemonSettings.max_key_fee);
const connectionOptions = [1, 4, 6, 10, 20];
const connectionOptions = [1, 2, 4, 6, 10, 20];
return (
<Page>
@ -209,10 +210,10 @@ class SettingsPage extends React.PureComponent<Props, State> {
name="save_files"
onChange={() => setDaemonSetting('save_files', !daemonSettings.save_files)}
checked={daemonSettings.save_files}
label={__(
'Enables saving of all viewed content to your downloads directory. Paid content and some file types are saved by default.'
label={__('Save all viewed content to your downloads directory')}
helper={__(
'Paid content and some file types are saved by default. Changing this setting will not affect previously downloaded content.'
)}
helper={__('This is not retroactive, only works from the time it was changed.')}
/>
</Form>
<Form>
@ -221,13 +222,13 @@ class SettingsPage extends React.PureComponent<Props, State> {
name="save_blobs"
onChange={() => setDaemonSetting('save_blobs', !daemonSettings.save_blobs)}
checked={daemonSettings.save_blobs}
label={
label={__('Save hosting data to help the LBRY network')}
helper={
<React.Fragment>
{__('Enables saving of hosting data to help the LBRY network.')}{' '}
{__("If disabled, LBRY will be very sad and you won't be helping improve the network.")}{' '}
<Button button="link" label={__('Learn more')} href="https://lbry.com/faq/host-content" />.
</React.Fragment>
}
helper={__("If disabled, LBRY will be very sad and you won't be helping improve the network")}
/>
</Form>
</section>
@ -313,19 +314,38 @@ class SettingsPage extends React.PureComponent<Props, State> {
<section className="card card--section">
<h2 className="card__title">{__('Content Settings')}</h2>
<FormField
type="checkbox"
name="floating_player"
onChange={() => {
setClientSetting(SETTINGS.FLOATING_PLAYER, !floatingPlayer);
}}
checked={floatingPlayer}
label={__('Floating video player')}
helper={__('Keep content playing in the corner when navigating to a different page.')}
/>
<Form>
<FormField
type="checkbox"
name="show_nsfw"
onChange={() => setClientSetting(SETTINGS.SHOW_NSFW, !showNsfw)}
checked={showNsfw}
label={__('Show mature content')}
helper={__(
'Mature content may include nudity, intense sexuality, profanity, or other adult content. By displaying mature content, you are affirming you are of legal age to view mature content in your country or jurisdiction. '
)}
/>
</Form>
<FormField
type="checkbox"
name="autoplay"
onChange={() => setClientSetting(SETTINGS.AUTOPLAY, !autoplay)}
checked={autoplay}
label={__('Autoplay media files')}
helper={__(
'Autoplay video and audio files when navigating to a file, as well as the next related item when a file finishes playing.'
)}
/>
<FormField
type="checkbox"
name="show_nsfw"
onChange={() => setClientSetting(SETTINGS.SHOW_NSFW, !showNsfw)}
checked={showNsfw}
label={__('Show mature content')}
helper={__(
'Mature content may include nudity, intense sexuality, profanity, or other adult content. By displaying mature content, you are affirming you are of legal age to view mature content in your country or jurisdiction. '
)}
/>
</section>
<section className="card card--section">
@ -465,17 +485,6 @@ class SettingsPage extends React.PureComponent<Props, State> {
)}
/>
<FormField
type="checkbox"
name="autoplay"
onChange={() => setClientSetting(SETTINGS.AUTOPLAY, !autoplay)}
checked={autoplay}
label={__('Autoplay media files')}
helper={__(
'Autoplay video and audio files when navigating to a file, as well as the next related item when a file finishes playing.'
)}
/>
<FormField
name="language_select"
type="select"
@ -499,11 +508,13 @@ class SettingsPage extends React.PureComponent<Props, State> {
name="max_connections"
type="select"
label={__('Max Connections')}
helper={__('More connections, like, do stuff dude')}
helper={__(
'For users with good bandwidth, try a higher value to improve streaming and download speeds. Low bandwidth users may benefit from a lower setting. Default is 4.'
)}
min={1}
max={100}
onChange={this.onMaxConnectionsChange}
value={maxConnections}
value={daemonSettings.max_connections_per_download}
>
{connectionOptions.map(connectionOption => (
<option key={connectionOption} value={connectionOption}>
@ -520,7 +531,7 @@ class SettingsPage extends React.PureComponent<Props, State> {
<p className="card__subtitle--status">
{__(
'This will clear the application cache. Your wallet will not be affected. Currently, followed tags will be cleared.'
'This will clear the application cache. Your wallet will not be affected. Currently, followed tags and blocked channels will be cleared.'
)}
</p>

View file

@ -1,5 +1,5 @@
// @flow
import React, { createRef } from 'react';
import React, { useRef } from 'react';
import Page from 'component/page';
import ClaimListDiscover from 'component/claimListDiscover';
import Button from 'component/button';
@ -18,7 +18,7 @@ function TagsPage(props: Props) {
followedTags,
doToggleTagFollow,
} = props;
const buttonRef = createRef();
const buttonRef = useRef();
const isHovering = useHover(buttonRef);
const urlParams = new URLSearchParams(search);

View file

@ -22,6 +22,7 @@ import {
doPurchaseUri,
makeSelectUriIsStreamable,
selectDownloadingByOutpoint,
makeSelectClaimForUri,
} from 'lbry-redux';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
@ -89,7 +90,7 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) {
// If notifications are disabled(false) just return
if (!selectosNotificationsEnabled(getState()) || !fileInfo.written_bytes) return;
const notif = new window.Notification('LBRY Download Complete', {
const notif = new window.Notification(__('LBRY Download Complete'), {
body: fileInfo.metadata.title,
silent: false,
});
@ -194,9 +195,6 @@ export function doPurchaseUriWrapper(uri: string, cost: number, saveFile: boolea
export function doPlayUri(uri: string, skipCostCheck: boolean = false, saveFileOverride: boolean = false) {
return (dispatch: Dispatch, getState: () => any) => {
// Set the active playing uri so we can avoid showing error notifications if a previously started download fails
dispatch(doSetPlayingUri(uri));
const state = getState();
const fileInfo = makeSelectFileInfoForUri(uri)(state);
const uriIsStreamable = makeSelectUriIsStreamable(uri)(state);
@ -243,8 +241,13 @@ export function doPlayUri(uri: string, skipCostCheck: boolean = false, saveFileO
};
}
export function savePosition(claimId: string, outpoint: string, position: number) {
return (dispatch: Dispatch) => {
export function savePosition(uri: string, position: number) {
return (dispatch: Dispatch, getState: () => any) => {
const state = getState();
const claim = makeSelectClaimForUri(uri)(state);
const { claim_id: claimId, txid, nout } = claim;
const outpoint = `${txid}:${nout}`;
dispatch({
type: ACTIONS.SET_CONTENT_POSITION,
data: { claimId, outpoint, position },

View file

@ -25,12 +25,13 @@ const defaultState = {
[SETTINGS.THEMES]: getLocalStorageSetting(SETTINGS.THEMES, []),
[SETTINGS.AUTOMATIC_DARK_MODE_ENABLED]: getLocalStorageSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false),
[SETTINGS.SUPPORT_OPTION]: getLocalStorageSetting(SETTINGS.SUPPORT_OPTION, false),
[SETTINGS.AUTOPLAY]: getLocalStorageSetting(SETTINGS.AUTOPLAY, false),
[SETTINGS.AUTOPLAY]: getLocalStorageSetting(SETTINGS.AUTOPLAY, true),
[SETTINGS.RESULT_COUNT]: Number(getLocalStorageSetting(SETTINGS.RESULT_COUNT, 50)),
[SETTINGS.AUTO_DOWNLOAD]: getLocalStorageSetting(SETTINGS.AUTO_DOWNLOAD, true),
[SETTINGS.OS_NOTIFICATIONS_ENABLED]: Boolean(getLocalStorageSetting(SETTINGS.OS_NOTIFICATIONS_ENABLED, true)),
[SETTINGS.HIDE_BALANCE]: Boolean(getLocalStorageSetting(SETTINGS.HIDE_BALANCE, false)),
[SETTINGS.HIDE_SPLASH_ANIMATION]: Boolean(getLocalStorageSetting(SETTINGS.HIDE_SPLASH_ANIMATION, false)),
[SETTINGS.FLOATING_PLAYER]: Boolean(getLocalStorageSetting(SETTINGS.FLOATING_PLAYER, true)),
},
isNight: false,
languages: { en: 'English', pl: 'Polish', id: 'Bahasa Indonesia' }, // temporarily hard code these so we can advance i18n testing

View file

@ -81,11 +81,11 @@
right: var(--spacing-miniscule);
padding: 0.3rem;
transition: all var(--transition-duration) var(--transition-style);
border-radius: var(--card-radius);
&:hover {
background-color: $lbry-black;
color: $lbry-white;
border-radius: var(--card-radius);
}
}

View file

@ -193,3 +193,12 @@
@extend .card__title;
justify-content: space-between;
}
.card__media--nsfw {
background-color: $lbry-grape-3;
}
.card__media--disabled {
opacity: 0.5;
pointer-events: none;
}

View file

@ -213,6 +213,9 @@ $border-color--dark: var(--dm-color-04);
margin-right: auto;
padding-right: var(--spacing-medium);
font-size: larger;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.claim-preview-metadata {

View file

@ -1,14 +1,80 @@
.content__cover {
// Video thumbnail with play/download button
top: 0;
left: 0;
bottom: 0;
right: 0;
.content__viewer {
@extend .card;
position: absolute;
top: var(--spacing-large);
}
.content__viewer--floating {
position: fixed;
margin-bottom: 0;
height: calc(var(--floating-viewer-height) + var(--floating-viewer-info-height));
overflow: hidden;
left: calc(var(--spacing-large) + var(--spacing-small));
z-index: 9999;
&:hover {
.content__actions {
visibility: visible;
}
}
}
.content__wrapper {
position: relative;
width: 100%;
height: 100%;
}
.content__wrapper--floating {
height: var(--floating-viewer-height);
width: var(--floating-viewer-width);
}
.content__actions {
visibility: hidden;
position: absolute;
right: var(--spacing-small);
top: var(--spacing-small);
z-index: 1;
display: flex;
.button {
position: relative;
margin-left: var(--spacing-small);
.icon:not(:hover) {
[data-mode='dark'] & {
stroke: $lbry-black;
}
}
}
}
.content__hide-viewer {
background-color: $lbry-white;
stroke: $lbry-black;
&:hover {
background-color: $lbry-teal-5;
}
}
.content__info {
cursor: grab;
height: var(--floating-viewer-info-height);
padding: var(--spacing-medium);
display: flex;
flex-direction: column;
align-items: flex-start;
white-space: nowrap;
}
.content__cover {
@include thumbnail;
position: relative;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: 100%;
@ -26,23 +92,6 @@
background-color: $lbry-teal-2;
}
}
}
.content__embedded {
@include thumbnail;
position: relative;
video {
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
&:hover {
cursor: pointer;
}
}
&:-webkit-full-screen {
width: 100%;
@ -50,26 +99,6 @@
}
}
.content__empty {
@include thumbnail;
align-items: center;
background-color: $lbry-black;
color: $lbry-white;
display: flex;
justify-content: center;
width: 100%;
}
.content__empty--nsfw,
.card__media--nsfw {
background-color: $lbry-grape-3;
}
.card__media--disabled {
opacity: 0.5;
pointer-events: none;
}
.content__loading {
position: absolute;
top: 0;

View file

@ -1,23 +1,8 @@
.file-render {
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: hidden;
position: absolute;
[data-mode='dark'] & {
border: 1px solid rgba($lbry-gray-1, 0.3);
}
}
.document-viewer {
background-color: $lbry-white;
[data-mode='dark'] & {
background-color: transparent;
}
}
.file-render__viewer {
@ -33,15 +18,14 @@
}
}
.file-render--pdf {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
}
.document-viewer {
.file-render__viewer--document {
@extend .file-render__viewer;
overflow: auto;
background-color: $lbry-white;
[data-mode='dark'] & {
background-color: transparent;
}
.markdown-preview {
height: 100%;
@ -50,18 +34,28 @@
}
}
.code-viewer,
.document-viewer__content {
.file-render__viewer--pdf {
@extend .file-render__viewer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
}
.file-render__content {
width: 100%;
height: 100%;
overflow: auto;
}
//
// Custom viewers live below here
// These either have custom class names that can't be changed or have styles that need to be ovverridden
//
// Code-viewer
.code-viewer .CodeMirror {
width: 100%;
height: 100%;
min-height: 100px;
.CodeMirror {
@extend .file-render__content;
.cm-invalidchar {
display: none;

View file

@ -1,5 +1,6 @@
.main-wrapper {
position: relative;
min-height: 100vh;
[data-mode='dark'] & {
background-color: var(--dm-color-08);

View file

@ -20,10 +20,12 @@ $large-breakpoint: 1921px;
--file-page-max-width: 1787px;
--file-max-height: 788px;
--file-max-width: 1400px;
--video-aspect-ratio: 56.25%; // 9 x 16
--floating-viewer-width: 32rem;
--floating-viewer-height: 18rem; // 32 * 9/16
--floating-viewer-info-height: 5rem;
--floating-viewer-container-height: calc(var(--floating-viewer-height) + var(--floating-viewer-info-height));
// Font
--font-multiplier-small: 0.9em;
--font-multiplier-medium: 1.1em;
--font-multiplier-large: 1.4em;

View file

@ -43,7 +43,7 @@ const fileInfoFilter = createFilter('fileInfo', [
'fileListDownloadedSort',
'fileListSubscriptionSort',
]);
const appFilter = createFilter('app', ['hasClickedComment', 'searchOptionsExpanded']);
const appFilter = createFilter('app', ['hasClickedComment', 'searchOptionsExpanded', 'volume', 'muted']);
// We only need to persist the receiveAddress for the wallet
const walletFilter = createFilter('wallet', ['receiveAddress']);
const searchFilter = createFilter('search', ['options']);

View file

@ -4,13 +4,19 @@ export default function usePersistedState(key, firstTimeDefault) {
// If no key is passed in, act as a normal `useState`
let defaultValue;
if (key) {
const item = localStorage.getItem(key);
if (item === 'true') {
defaultValue = true;
} else if (item === 'false') {
defaultValue = false;
} else {
defaultValue = item;
let item = localStorage.getItem(key);
if (item) {
let parsedItem;
try {
parsedItem = JSON.parse(item);
} catch (e) {}
if (parsedItem) {
defaultValue = parsedItem;
} else {
defaultValue = item;
}
}
}
@ -22,7 +28,7 @@ export default function usePersistedState(key, firstTimeDefault) {
useEffect(() => {
if (key) {
localStorage.setItem(key, value);
localStorage.setItem(key, typeof value === 'object' ? JSON.stringify(value) : value);
}
}, [key, value]);

View file

@ -9566,6 +9566,14 @@ react-dom@^16.8.2, react-dom@^16.8.6:
prop-types "^15.6.2"
scheduler "^0.13.6"
react-draggable@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.3.0.tgz#2ed7ea3f92e7d742d747f9e6324860606cd4d997"
integrity sha512-U7/jD0tAW4T0S7DCPK0kkKLyL0z61sC/eqU+NUfDjnq+JtBKaYKDHpsK2wazctiA4alEzCXUnzkREoxppOySVw==
dependencies:
classnames "^2.2.5"
prop-types "^15.6.0"
react-ga@^2.5.7:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-2.5.7.tgz#1c80a289004bf84f84c26d46f3a6a6513081bf2e"