diff --git a/static/img/dark_loading.gif b/static/img/dark_loading.gif new file mode 100644 index 000000000..edb99a6eb Binary files /dev/null and b/static/img/dark_loading.gif differ diff --git a/static/img/white_loading.gif b/static/img/white_loading.gif new file mode 100644 index 000000000..b65b4c25a Binary files /dev/null and b/static/img/white_loading.gif differ diff --git a/ui/component/claimPreview/index.js b/ui/component/claimPreview/index.js index b63696f69..15b48cc7e 100644 --- a/ui/component/claimPreview/index.js +++ b/ui/component/claimPreview/index.js @@ -26,6 +26,7 @@ import { selectShowMatureContent } from 'redux/selectors/settings'; import { makeSelectHasVisitedUri } from 'redux/selectors/content'; import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; import { selectModerationBlockList } from 'redux/selectors/comments'; +import { doFileGet } from 'redux/actions/file'; import ClaimPreview from './view'; import formatMediaDuration from 'util/formatMediaDuration'; diff --git a/ui/component/claimPreviewTile/view.jsx b/ui/component/claimPreviewTile/view.jsx index 1c921f64e..1c1bd1116 100644 --- a/ui/component/claimPreviewTile/view.jsx +++ b/ui/component/claimPreviewTile/view.jsx @@ -187,7 +187,7 @@ function ClaimPreviewTile(props: Props) {
@@ -247,7 +247,7 @@ function ClaimPreviewTile(props: Props) {
{isChannel ? ( diff --git a/ui/component/downloadProgress/index.js b/ui/component/downloadProgress/index.js new file mode 100644 index 000000000..d5a4fa943 --- /dev/null +++ b/ui/component/downloadProgress/index.js @@ -0,0 +1,48 @@ +import { connect } from 'react-redux'; +import DownloadProgress from './view'; +import { doSetPlayingUri, doStopDownload, doContinueDownloading, doPurchaseUriWrapper } from 'redux/actions/content'; +import { selectFileInfosByOutpoint, SETTINGS } from 'lbry-redux'; +import { selectPrimaryUri, selectPlayingUri } from 'redux/selectors/content'; +import { makeSelectClientSetting } from 'redux/selectors/settings'; + +const select = (state) => { + const byOutpoint = selectFileInfosByOutpoint(state); + const runningByOutpoint = []; + const primaryUri = selectPrimaryUri(state); + const playingUri = selectPlayingUri(state); + const uri = playingUri ? playingUri.uri : null; + let primaryOutpoint = null; + let playingOutpoint = null; + + for (const key in byOutpoint) { + const item = byOutpoint[key]; + + if (item && primaryUri && primaryUri.includes(`/${item.claim_name}`)) primaryOutpoint = item.outpoint; + if (item && uri && uri.includes(`/${item.claim_name}`)) playingOutpoint = item.outpoint; + + if (item && item.status === 'running') { + runningByOutpoint.push(item); + } + } + + return { + byOutpoint: selectFileInfosByOutpoint(state), + primary: { + uri: primaryUri, + outpoint: primaryOutpoint, + }, + playing: { + uri, + outpoint: playingOutpoint, + }, + currentTheme: makeSelectClientSetting(SETTINGS.THEME)(state), + }; +}; + +const perform = (dispatch) => ({ + pause: () => dispatch(doSetPlayingUri({ uri: null })), + doContinueDownloading: (outpoint, force) => dispatch(doContinueDownloading(outpoint, force)), + stopDownload: (outpoint) => dispatch(doStopDownload(outpoint)), + download: (uri) => dispatch(doPurchaseUriWrapper(uri, false, true)), +}); +export default connect(select, perform)(DownloadProgress); diff --git a/ui/component/downloadProgress/view.jsx b/ui/component/downloadProgress/view.jsx new file mode 100644 index 000000000..dd95b5c7c --- /dev/null +++ b/ui/component/downloadProgress/view.jsx @@ -0,0 +1,286 @@ +// @flow +import React, { useState, useEffect } from 'react'; +import { shell } from 'electron'; +import Button from 'component/button'; +import * as ICONS from 'constants/icons'; +import { buildURI } from 'lbry-redux'; +import { formatBytes } from 'util/format-bytes'; +import { areEqual, removeItem } from 'util/array'; +import loadingIcon from '../../../static/img/white_loading.gif'; +import darkLoadingIcon from '../../../static/img/dark_loading.gif'; +import usePersistedState from 'effects/use-persisted-state'; + +type Props = { + byOutpoint: any, + primary: any, + playing: any, + currentTheme: string, + stopDownload: (outpoint: string) => void, + doContinueDownloading: (outpoint: string, force: boolean) => void, + download: (uri: string) => void, +}; + +function DownloadProgress({ byOutpoint, primary, playing, currentTheme, stopDownload, doContinueDownloading }: Props) { + const [isShow, setIsShow] = usePersistedState('download-progress', true); + const [downloading, setDownloading] = usePersistedState('download-progress-downloading', []); + const [cancelHash] = useState({}); + const [initDownloadingHash] = useState({}); + const [prevPlaying, setPrevPlaying] = useState({}); + const [prevPrimary, setPrevPrimary] = useState({}); + + const handleCancel = (hash, value) => { + cancelHash[hash] = value; + }; + + const handleStopDownload = (outpoint) => { + const updated = [...downloading]; + removeItem(updated, outpoint); + setDownloading(updated); + stopDownload(outpoint); + }; + + const runningByOutpoint = {}; + const currentDownloading = [...downloading]; + + for (const key in byOutpoint) { + const item = byOutpoint[key]; + if (item && item.status === 'running') runningByOutpoint[item.outpoint] = item; + } + + Object.keys(runningByOutpoint) + .filter((outpoint) => downloading.indexOf(outpoint) === -1) + .map((outpoint) => { + if (primary.outpoint !== outpoint && playing.outpoint !== outpoint) { + currentDownloading.push(outpoint); + } + }); + + downloading + .filter((outpoint) => (byOutpoint[outpoint] && byOutpoint[outpoint].status !== 'running') || !byOutpoint[outpoint]) + .map((outpoint) => { + removeItem(currentDownloading, outpoint); + }); + if (!areEqual(downloading, currentDownloading)) setDownloading(currentDownloading); + + if (currentDownloading.length === 0) return null; + + if (playing.outpoint !== prevPlaying.outpoint) { + if (downloading.includes(prevPlaying.outpoint)) { + setTimeout(() => { + doContinueDownloading(prevPlaying.outpoint, true); + }, 1000); + } + setPrevPlaying(playing); + } + + if (primary.outpoint !== prevPrimary.outpoint) { + if (downloading.includes(prevPrimary.outpoint)) { + setTimeout(() => { + doContinueDownloading(prevPrimary.outpoint, true); + }, 1000); + } + setPrevPrimary(primary); + } + + currentDownloading.map((outpoint) => { + if (!initDownloadingHash[outpoint]) { + initDownloadingHash[outpoint] = true; + doContinueDownloading(outpoint, false); + } + }); + + if (!isShow) { + return ( + <> + + + ); + } + + return ( +
+ + + {currentDownloading.map((outpoint, index) => { + const item = runningByOutpoint[outpoint]; + let releaseTime = ''; + let isPlaying = false; + if (item.metadata && item.metadata.release_time) { + releaseTime = new Date(parseInt(item.metadata.release_time) * 1000).toISOString().split('T')[0]; + } + if (outpoint === primary.outpoint || outpoint === playing.outpoint) { + isPlaying = true; + } + return ( +
+ {index !== 0 &&
} + +
+ ); + })} +
+ ); +} + +type DownloadProgressItemProps = { + fileName: string, + writtenBytes: number, + totalBytes: number, + addedOn: number, + title: string, + releaseTime: string, + directory: string, + outpoint: string, + isCancel: boolean, + claimID: string, + claimName: string, + playing: boolean, + currentTheme: string, + stopDownload: (outpoint: string) => void, + handleCancel: (hash: string, value: boolean) => void, +}; + +function DownloadProgressItem({ + fileName, + writtenBytes, + totalBytes, + addedOn, + title, + releaseTime, + directory, + outpoint, + isCancel, + claimID, + claimName, + playing, + currentTheme, + stopDownload, + handleCancel, +}: DownloadProgressItemProps) { + const processStopDownload = () => { + handleCancel(outpoint, false); + stopDownload(outpoint); + }; + + const [percent, setPercent] = useState(0); + const [progressText, setProgressText] = useState(''); + + useEffect(() => { + const updatePercent = ((writtenBytes / totalBytes) * 100).toFixed(0); + setPercent(updatePercent); + + let updateText = ''; + const downloadSpeed = Math.ceil(writtenBytes / (Date.now() / 1000 - addedOn)); + const remainingSecond = Math.ceil((totalBytes - writtenBytes) / downloadSpeed); + const remainingMinutes = Math.floor(remainingSecond / 60); + + if (remainingMinutes > 0) { + updateText += __('%remainingMinutes% minutes %remainSecond% seconds remaining', { + remainingMinutes: remainingMinutes, + remainSecond: remainingSecond - 60 * remainingMinutes, + }); + } else { + updateText += __('%remainSecond% seconds remaining', { remainSecond: remainingSecond - 60 * remainingMinutes }); + } + updateText += ' -- '; + + updateText += __('%written% of %total%', { + written: formatBytes(writtenBytes), + total: formatBytes(totalBytes), + }); + updateText += ' '; + + updateText += __('(%speed%/sec)', { + speed: formatBytes(downloadSpeed), + }); + + setProgressText(updateText); + }, [writtenBytes, totalBytes, addedOn]); + + const openDownloadFolder = () => { + shell.openPath(directory); + }; + return ( +
+
+
+
+ + {fileName} + +

{releaseTime}

+
+
+
+
+
+
+

{progressText}

+ {isCancel && ( +
+

{__('Do you cancel download this file?')}

+
+
+
+ )} +
+ ); +} + +export default DownloadProgress; diff --git a/ui/component/page/view.jsx b/ui/component/page/view.jsx index 1cb21c7ae..5fac4f8e8 100644 --- a/ui/component/page/view.jsx +++ b/ui/component/page/view.jsx @@ -5,6 +5,7 @@ import classnames from 'classnames'; import SideNavigation from 'component/sideNavigation'; import SettingsSideNavigation from 'component/settingsSideNavigation'; import Header from 'component/header'; +import DownloadProgress from 'component/downloadProgress'; /* @if TARGET='app' */ import StatusBar from 'component/common/status-bar'; /* @endif */ diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 50f229231..4adef7722 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -150,6 +150,14 @@ function VideoViewer(props: Props) { bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds); } + // convert bytes to bits, and then divide by seconds + const contentInBits = Number(claim.value.source.size) * 8; + const durationInSeconds = claim.value.video && claim.value.video.duration; + let bitrateAsBitsPerSecond; + if (durationInSeconds) { + bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds); + } + fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => { let playerPoweredBy = response.headers.get('x-powered-by') || ''; analytics.videoStartEvent( diff --git a/ui/redux/actions/content.js b/ui/redux/actions/content.js index 045987665..0cff1836f 100644 --- a/ui/redux/actions/content.js +++ b/ui/redux/actions/content.js @@ -14,29 +14,30 @@ import { } from 'redux/selectors/file_info'; import { makeSelectUrlsForCollectionId } from 'redux/selectors/collections'; import { doToast } from 'redux/actions/notifications'; -import { doPurchaseUri } from 'redux/actions/file'; +import { doPurchaseUri, doDeleteFile } from 'redux/actions/file'; import Lbry from 'lbry'; import * as SETTINGS from 'constants/settings'; import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc'; import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings'; const DOWNLOAD_POLL_INTERVAL = 1000; +var timeOutHash = {}; -export function doUpdateLoadStatus(uri: string, outpoint: string) { +export function doUpdateLoadStatus(uri: any, outpoint: string) { // Updates the loading status for a uri as it's downloading // Calls file_list and checks the written_bytes value to see if the number has increased // Not needed on web as users aren't actually downloading the file // @if TARGET='app' return (dispatch: Dispatch, getState: GetState) => { const setNextStatusUpdate = () => - setTimeout(() => { + (timeOutHash[outpoint] = setTimeout(() => { // We need to check if outpoint still exists first because user are able to delete file (outpoint) while downloading. // If a file is already deleted, no point to still try update load status const byOutpoint = selectFileInfosByOutpoint(getState()); if (byOutpoint[outpoint]) { dispatch(doUpdateLoadStatus(uri, outpoint)); } - }, DOWNLOAD_POLL_INTERVAL); + }, DOWNLOAD_POLL_INTERVAL)); Lbry.file_list({ outpoint, @@ -96,6 +97,25 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) { // @endif } +export function doContinueDownloading(outpoint: string, force: boolean) { + return (dispatch: Dispatch) => { + if (!timeOutHash[outpoint] || force) { + dispatch(doUpdateLoadStatus(null, outpoint)); + } + }; +} + +export function doStopDownload(outpoint: string) { + return (dispatch: Dispatch) => { + if (timeOutHash[outpoint]) { + clearInterval(timeOutHash[outpoint]); + timeOutHash[outpoint] = undefined; + } + + dispatch(doDeleteFile(outpoint, false, false, null)); + }; +} + export function doSetPrimaryUri(uri: ?string) { return (dispatch: Dispatch) => { dispatch({ diff --git a/ui/scss/all.scss b/ui/scss/all.scss index 77ca90423..6911dc68b 100644 --- a/ui/scss/all.scss +++ b/ui/scss/all.scss @@ -66,3 +66,4 @@ @import 'component/empty'; @import 'component/stripe-card'; @import 'component/wallet-tip-send'; +@import 'component/download-progress'; diff --git a/ui/scss/component/_claim-list.scss b/ui/scss/component/_claim-list.scss index 8aed6c484..de482cc48 100644 --- a/ui/scss/component/_claim-list.scss +++ b/ui/scss/component/_claim-list.scss @@ -720,6 +720,7 @@ margin: 0 0; padding: var(--spacing-xxs) var(--spacing-xxs); height: unset; + background-color: var(--color-header-background); // label (with 'Add' text) hidden by default .button__label { diff --git a/ui/scss/component/_download-progress.scss b/ui/scss/component/_download-progress.scss new file mode 100644 index 000000000..5b0f29346 --- /dev/null +++ b/ui/scss/component/_download-progress.scss @@ -0,0 +1,224 @@ +.download-progress__header { + padding: 15px; + position: fixed; + bottom: 0; + right: 0; + width: 400px; + display: flex; + flex-direction: column; + background-color: var(--color-header-background); //var(--color-gray-9):dark-mode + border-radius: var(--border-radius); + // border: 1px solid var(--color-gray-3); + z-index: 9999; +} +.download-progress__top-close-button { + position: absolute; + top: 7px; + right: 15px; + font-size: 35px; + background-color: transparent; + width: 15px; + height: 15px; + div { + height: 2px; + width: 13px; + background-color: var(--color-gray-4); + border-radius: var(--border-radius); + } +} +.download-progress__state-container { + margin-top: 10px; + padding-bottom: 10px; + display: flex; + flex-direction: column; + width: 100%; +} +.download-progress__state-filename { + margin: 0; + font-weight: 800; + font-size: 13px; + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 10px; + span.button__label { + color: var(--color-text); + } +} +.download-progress__state-filename-link { + margin: 0; + font-weight: 800; + font-size: 13px; + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 10px; + text-decoration: underline; + cursor: pointer; +} +.download-progress__release-time { + margin: 0; + font-weight: 800; + font-size: 12px; + margin-left: auto; + font-style: italic; +} +.download-progress__state-bar { + display: flex; + flex-direction: row; + width: 100%; + align-items: center; + margin-bottom: 2px; +} +.download-progress__bar-container { + width: 100%; + background-color: var(--color-gray-5); + height: 6px; + border-radius: var(--border-radius); +} +.download-progress__bar-content { + border-radius: var(--border-radius); + height: 100%; + background-color: var(--color-primary); +} +.download-progress__close-button { + flex-shrink: 0; + margin-left: auto; + font-size: 20px; + cursor: pointer; +} +.download-progress__playing-button { + flex-shrink: 0; + margin-left: auto; + width: 29.6px; + height: 29.6px; +} +.download-progress__count-time { + font-size: 11px; + letter-spacing: -0.6px; +} +.download-progress__divider { + border-top: 1px solid var(--color-gray-6); + margin-left: -15px; + width: 110%; +} +.download-progress__cancel { + margin-top: 7px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} +.download-progress__cancel p { + margin: 0; + font-size: 12px; + margin: 0; + white-space: nowrap; + width: 285px; + overflow: hidden; + text-overflow: ellipsis; +} +.download-progress__cancel b { + font-size: 13px; +} +.download-progress__cancel-confirm { + width: 90px; + display: flex; + justify-content: space-around; + align-items: center; +} +.download-progress__cancel-ok { + border: none; + background-color: transparent; + font-size: 13px; + color: var(--color-text); +} +.download-progress__cancel-ok:hover { + color: var(--color-primary); +} +.download__container { + position: fixed; + bottom: 10px; + right: 10px; + width: 400px; + height: 300px; + border-radius: var(--border-radius); + box-shadow: 2px 2px 5px var(--color-gray-4); + background-color: var(--color-white); + transition: width 2s; +} +.download-progress__toggle-button { + position: fixed; + bottom: 10px; + right: 10px; + border: none; + background: var(--color-header-background); + color: var(--color-gray-6); + width: 50px; + height: 50px; + border-radius: var(--border-radius); + box-shadow: 0px 5px 4px var(--color-gray-4); + display: flex; + justify-content: center; + align-items: center; +} +.download_close_modal { + float: right; + margin-right: 10px; + font-size: 25px; +} +.download-progress__current-downloading { + position: fixed; + bottom: 25px; + right: 15px; + border: none; + width: 30px; + height: 30px; + display: flex; + justify-content: center; + align-items: center; + margin: 0; + animation-name: downloadcount; + animation-duration: 1.3s; + animation-iteration-count: infinite; + + .notification__bubble { + height: 1.5rem; + width: 1.5rem; + border-radius: 50%; + background-color: var(--color-editor-tag); + position: absolute; + top: -0.5rem; + right: -0.5rem; + color: white; + font-size: var(--font-small); + font-weight: bold; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + } + + .notification__bubble--small { + font-size: var(--font-xsmall); + } + + .notification__bubble--inline { + @extend .notification__bubble; + top: 0.75rem; + right: 1rem; + } +} +@keyframes downloadcount { + 0% { + transform: translateY(-10px); + } + 50% { + transform: translateY(-3px); + } + 100% { + transform: translateY(-10px); + } +} diff --git a/ui/scss/component/menu-button.scss b/ui/scss/component/menu-button.scss index 593ffa47a..03b097903 100644 --- a/ui/scss/component/menu-button.scss +++ b/ui/scss/component/menu-button.scss @@ -121,6 +121,12 @@ overflow-x: hidden; } +.menu__link-disable { + @extend .menu__link; + color: var(--color-text-subtitle)!important; +} + + .menu__link--notification { width: 100%; display: flex; diff --git a/ui/util/array.js b/ui/util/array.js new file mode 100644 index 000000000..4badb3006 --- /dev/null +++ b/ui/util/array.js @@ -0,0 +1,18 @@ +export function areEqual(first, second) { + if (first.length !== second.length) { + return false; + } + for (let i = 0; i < first.length; i++) { + if (!second.includes(first[i])) { + return false; + } + } + return true; +} + +export function removeItem(array, item) { + const index = array.indexOf(item); + if (index > -1) { + array.splice(index, 1); + } +} diff --git a/ui/util/livestream.js b/ui/util/livestream.js new file mode 100644 index 000000000..2cf026194 --- /dev/null +++ b/ui/util/livestream.js @@ -0,0 +1,24 @@ +// @flow + +/** + * Helper to extract livestream claim uris from the output of + * `selectActiveLivestreams`. + * + * @param activeLivestreams Object obtained from `selectActiveLivestreams`. + * @param channelIds List of channel IDs to filter the results with. + * @returns {[]|Array<*>} + */ +export function getLivestreamUris(activeLivestreams: ?LivestreamInfo, channelIds: ?Array) { + let values = (activeLivestreams && Object.values(activeLivestreams)) || []; + + if (channelIds && channelIds.length > 0) { + // $FlowFixMe + values = values.filter((v) => channelIds.includes(v.creatorId) && Boolean(v.latestClaimUri)); + } else { + // $FlowFixMe + values = values.filter((v) => Boolean(v.latestClaimUri)); + } + + // $FlowFixMe + return values.map((v) => v.latestClaimUri); +} diff --git a/ui/util/remark-lbry.js b/ui/util/remark-lbry.js index 1350370dc..d0850bc50 100644 --- a/ui/util/remark-lbry.js +++ b/ui/util/remark-lbry.js @@ -26,6 +26,21 @@ function handlePunctuation(value) { return punctuationIndex ? value.substring(0, punctuationIndex) : value; } +function handlePunctuation(value) { + const modifierIndex = + (value.indexOf(':') >= 0 && value.indexOf(':')) || (value.indexOf('#') >= 0 && value.indexOf('#')); + + let punctuationIndex; + punctuationMarks.some((p) => { + if (modifierIndex) { + punctuationIndex = value.indexOf(p, modifierIndex + 1) >= 0 && value.indexOf(p, modifierIndex + 1); + } + return punctuationIndex; + }); + + return punctuationIndex ? value.substring(0, punctuationIndex) : value; +} + // Find channel mention function locateMention(value, fromIndex) { const index = value.indexOf(mentionToken, fromIndex);