Playlist fall out fixes (#7032)

* Add snack bar notification

* Fix and improve code

* Better handle paid content on playlists

* Fix menu options that show for unauth users
This commit is contained in:
saltrafael 2021-09-10 14:27:21 -03:00 committed by GitHub
parent f6683d3c49
commit e8d8dfa76b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 391 additions and 276 deletions

View file

@ -15,8 +15,11 @@ type Props = {
nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string,
modal: { id: string, modalProps: {} },
skipPaid: boolean,
doNavigate: () => void,
doReplay: () => void,
doPrevious: () => void,
onCanceled: () => void,
};
function AutoplayCountdown(props: Props) {
@ -25,8 +28,11 @@ function AutoplayCountdown(props: Props) {
nextRecommendedClaim,
history: { push },
modal,
skipPaid,
doNavigate,
doReplay,
doPrevious,
onCanceled,
} = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -68,6 +74,7 @@ function AutoplayCountdown(props: Props) {
interval = setInterval(() => {
const newTime = timer - 1;
if (newTime === 0) {
if (skipPaid) setTimer(countdownTime);
doNavigate();
} else {
setTimer(timer - 1);
@ -78,7 +85,7 @@ function AutoplayCountdown(props: Props) {
return () => {
clearInterval(interval);
};
}, [timer, doNavigate, push, timerCanceled, isTimerPaused, nextRecommendedUri]);
}, [timer, doNavigate, push, timerCanceled, isTimerPaused, nextRecommendedUri, skipPaid]);
if (timerCanceled || !nextRecommendedUri) {
return null;
@ -105,19 +112,41 @@ function AutoplayCountdown(props: Props) {
)}
{!isTimerPaused && (
<div className="file-viewer__overlay-secondary autoplay-countdown__counter">
{__('Playing in %seconds_left% seconds...', { seconds_left: timer })}{' '}
<Button label={__('Cancel')} button="link" onClick={() => setTimerCanceled(true)} />
{__('Playing %message% in %seconds_left% seconds...', {
message: skipPaid ? __('next free content') : __(''),
seconds_left: timer,
})}{' '}
<Button
label={__('Cancel')}
button="link"
onClick={() => {
setTimerCanceled(true);
if (onCanceled) onCanceled();
}}
/>
</div>
)}
<Button
label={__('Replay?')}
button="link"
iconRight={ICONS.REPLAY}
onClick={() => {
setTimerCanceled(true);
doReplay();
}}
/>
{skipPaid && doPrevious && (
<div>
<Button
label={__('Play Previous')}
button="link"
icon={ICONS.PLAY_PREVIOUS}
onClick={() => doPrevious()}
/>
</div>
)}
<div>
<Button
label={skipPaid ? __('Purchase?') : __('Replay?')}
button="link"
iconRight={skipPaid ? ICONS.WALLET : ICONS.REPLAY}
onClick={() => {
setTimerCanceled(true);
doReplay();
}}
/>
</div>
</div>
</div>
</div>

View file

@ -31,14 +31,13 @@ import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscrip
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
import ClaimPreview from './view';
import fs from 'fs';
const select = (state, props) => {
const claim = makeSelectClaimForUri(props.uri, false)(state);
const collectionId = props.collectionId;
const resolvedList = makeSelectUrlsForCollectionId(collectionId)(state);
const repostedClaim = claim && claim.reposted_claim;
const contentClaim = repostedClaim || claim;
const contentSigningChannel = contentClaim && contentClaim.signing_channel;
@ -73,7 +72,7 @@ const select = (state, props) => {
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
resolvedList,
resolvedList: makeSelectUrlsForCollectionId(collectionId)(state),
playNextUri,
};
};
@ -102,8 +101,10 @@ const perform = (dispatch) => ({
doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)),
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),
fetchCollectionItems: (collectionId) => dispatch(doFetchItemsInCollection({ collectionId })),
doSetPlayingUri: (uri) => dispatch(doSetPlayingUri({ uri })),
doToggleShuffleList: (collectionId) => dispatch(doToggleShuffleList(undefined, collectionId, true, true)),
doToggleShuffleList: (collectionId) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
},
});
export default connect(select, perform)(ClaimPreview);

View file

@ -7,7 +7,13 @@ import React from 'react';
import classnames from 'classnames';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import { generateShareUrl, generateRssUrl, generateLbryContentUrl, formatLbryUrlForWeb } from 'util/url';
import {
generateShareUrl,
generateRssUrl,
generateLbryContentUrl,
formatLbryUrlForWeb,
generateListSearchUrlParams,
} from 'util/url';
import { useHistory } from 'react-router';
import { buildURI, parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
@ -59,7 +65,6 @@ type Props = {
playNextUri: string,
resolvedList: boolean,
fetchCollectionItems: (string) => void,
doSetPlayingUri: (string) => void,
doToggleShuffleList: (string) => void,
};
@ -101,7 +106,6 @@ function ClaimMenuList(props: Props) {
playNextUri,
resolvedList,
fetchCollectionItems,
doSetPlayingUri,
doToggleShuffleList,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
@ -129,15 +133,15 @@ function ClaimMenuList(props: Props) {
if (doShuffle && resolvedList) {
doToggleShuffleList(collectionId);
if (playNextUri) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
const navigateUrl = formatLbryUrlForWeb(playNextUri) + `?` + collectionParams.toString();
setDoShuffle(false);
doSetPlayingUri(playNextUri);
push(navigateUrl);
const navigateUrl = formatLbryUrlForWeb(playNextUri);
push({
pathname: navigateUrl,
search: generateListSearchUrlParams(collectionId),
state: { collectionId, forceAutoplay: true },
});
}
}
}, [collectionId, doSetPlayingUri, doShuffle, doToggleShuffleList, playNextUri, push, resolvedList]);
}, [collectionId, doShuffle, doToggleShuffleList, playNextUri, push, resolvedList]);
if (!claim) {
return null;
@ -259,6 +263,7 @@ function ClaimMenuList(props: Props) {
push(`/$/${PAGES.REPORT_CONTENT}?claimId=${contentClaim && contentClaim.claim_id}`);
}
const shouldShow = !IS_WEB || (IS_WEB && isAuthenticated);
return (
<Menu>
<MenuButton
@ -271,94 +276,93 @@ function ClaimMenuList(props: Props) {
<Icon size={20} icon={ICONS.MORE_VERTICAL} />
</MenuButton>
<MenuList className="menu__list">
{(!IS_WEB || (IS_WEB && isAuthenticated)) && (
<>
<>
{/* COLLECTION OPERATIONS */}
{collectionId && isCollectionClaim ? (
<>
{/* COLLECTION OPERATIONS */}
{collectionId && isCollectionClaim ? (
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
</a>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => {
if (!resolvedList) fetchItems();
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
{isMyCollection && (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
</a>
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
>
<div className="menu__link">
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
{editedCollection ? __('Publish') : __('Edit List')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => {
if (!resolvedList) fetchItems();
setDoShuffle(true);
}}
onSelect={() => openModal(MODALS.COLLECTION_DELETE, { collectionId })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete List')}
</div>
</MenuItem>
{isMyCollection && (
<>
<MenuItem
className="comment__menu-option"
onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}?view=edit`)}
>
<div className="menu__link">
<Icon aria-hidden iconColor={'red'} icon={ICONS.PUBLISH} />
{editedCollection ? __('Publish') : __('Edit List')}
</div>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => openModal(MODALS.COLLECTION_DELETE, { collectionId })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.DELETE} />
{__('Delete List')}
</div>
</MenuItem>
</>
)}
</>
) : (
isPlayable && (
<>
{/* WATCH LATER */}
<MenuItem
className="comment__menu-option"
onSelect={() =>
handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)
}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
{hasClaimInWatchLater ? __('In Watch Later') : __('Watch Later')}
</div>
</MenuItem>
{/* FAVORITES LIST */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInFavorites, __('Favorites'), COLLECTIONS_CONSTS.FAVORITES_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInFavorites ? ICONS.DELETE : ICONS.STAR} />
{hasClaimInFavorites ? __('In Favorites') : __('Favorites')}
</div>
</MenuItem>
{/* CURRENTLY ONLY SUPPORT PLAYLISTS FOR PLAYABLE; LATER DIFFERENT TYPES */}
<MenuItem
className="comment__menu-option"
onSelect={() => openModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.STACK} />
{__('Add to Lists')}
</div>
</MenuItem>
<hr className="menu__separator" />
</>
)
)}
</>
) : (
shouldShow &&
isPlayable && (
<>
{/* WATCH LATER */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInWatchLater, __('Watch Later'), COLLECTIONS_CONSTS.WATCH_LATER_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInWatchLater ? ICONS.DELETE : ICONS.TIME} />
{hasClaimInWatchLater ? __('In Watch Later') : __('Watch Later')}
</div>
</MenuItem>
{/* FAVORITES LIST */}
<MenuItem
className="comment__menu-option"
onSelect={() => handleAdd(hasClaimInFavorites, __('Favorites'), COLLECTIONS_CONSTS.FAVORITES_ID)}
>
<div className="menu__link">
<Icon aria-hidden icon={hasClaimInFavorites ? ICONS.DELETE : ICONS.STAR} />
{hasClaimInFavorites ? __('In Favorites') : __('Favorites')}
</div>
</MenuItem>
{/* CURRENTLY ONLY SUPPORT PLAYLISTS FOR PLAYABLE; LATER DIFFERENT TYPES */}
<MenuItem
className="comment__menu-option"
onSelect={() => openModal(MODALS.COLLECTION_ADD, { uri, type: 'playlist' })}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.STACK} />
{__('Add to Lists')}
</div>
</MenuItem>
<hr className="menu__separator" />
</>
)
)}
</>
{shouldShow && (
<>
{!isChannelPage && (
<>
<MenuItem className="comment__menu-option" onSelect={handleSupport}>
@ -435,9 +439,9 @@ function ClaimMenuList(props: Props) {
)}
</>
)}
<hr className="menu__separator" />
</>
)}
<hr className="menu__separator" />
<MenuItem className="comment__menu-option" onSelect={handleCopyLink}>
<div className="menu__link">

View file

@ -10,9 +10,9 @@ import ChannelThumbnail from 'component/channelThumbnail';
import FileViewCountInline from 'component/fileViewCountInline';
import SubscribeButton from 'component/subscribeButton';
import useGetThumbnail from 'effects/use-get-thumbnail';
import { formatLbryUrlForWeb } from 'util/url';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import { formatClaimPreviewTitle } from 'util/formatAriaLabel';
import { parseURI, COLLECTIONS_CONSTS, isURIEqual } from 'lbry-redux';
import { parseURI, isURIEqual } from 'lbry-redux';
import PreviewOverlayProperties from 'component/previewOverlayProperties';
import FileDownloadLink from 'component/fileDownloadLink';
import FileWatchLaterLink from 'component/fileWatchLaterLink';
@ -98,13 +98,9 @@ function ClaimPreviewTile(props: Props) {
const thumbnailUrl = useGetThumbnail(uri, claim, streamingUrl, getFile, placeholder) || thumbnail;
const canonicalUrl = claim && claim.canonical_url;
const permanentUrl = claim && claim.permanent_url;
let navigateUrl = formatLbryUrlForWeb(canonicalUrl || uri || '/');
const listId = collectionId || collectionClaimId;
if (listId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, listId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
const navigateUrl =
formatLbryUrlForWeb(canonicalUrl || uri || '/') + (listId ? generateListSearchUrlParams(listId) : '');
const navLinkProps = {
to: navigateUrl,
onClick: (e) => e.stopPropagation(),

View file

@ -1,17 +1,13 @@
import { connect } from 'react-redux';
import {
makeSelectClaimIsMine,
makeSelectClaimForUri,
selectMyChannelClaims,
makeSelectClaimIsPending,
makeSelectCollectionIsMine,
makeSelectEditedCollectionForId,
} from 'lbry-redux';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { doToast } from 'redux/actions/notifications';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doPlayUri, doSetPlayingUri, doToggleShuffleList, doToggleLoopList } from 'redux/actions/content';
import { doToggleShuffleList, doToggleLoopList } from 'redux/actions/content';
import CollectionActions from './view';
const select = (state, props) => {
@ -31,29 +27,23 @@ const select = (state, props) => {
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
const playNextClaim = makeSelectClaimForUri(playNextUri)(state);
return {
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
costInfo: makeSelectCostInfoForUri(props.uri)(state),
myChannels: selectMyChannelClaims(state),
claimIsPending: makeSelectClaimIsPending(props.uri)(state),
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(collectionId)(state)),
firstItem,
playNextUri,
playNextClaim,
};
};
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)),
doPlayUri: (uri) => dispatch(doPlayUri(uri)),
doSetPlayingUri: (uri) => dispatch(doSetPlayingUri({ uri })),
doToggleShuffleList: (collectionId, shuffle) => dispatch(doToggleShuffleList(undefined, collectionId, shuffle, true)),
doToggleLoopList: (collectionId, loop) => dispatch(doToggleLoopList(collectionId, loop)),
doToggleShuffleList: (collectionId, shuffle) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, shuffle, true));
},
});
export default connect(select, perform)(CollectionActions);

View file

@ -11,15 +11,12 @@ import { useHistory } from 'react-router';
import { EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
import classnames from 'classnames';
import { ENABLE_FILE_REACTIONS } from 'config';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb } from 'util/url';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
type Props = {
uri: string,
claim: StreamClaim,
openModal: (id: string, { uri: string, claimIsMine?: boolean, isSupport?: boolean }) => void,
myChannels: ?Array<ChannelClaim>,
doToast: ({ message: string }) => void,
openModal: (id: string, {}) => void,
claimIsPending: boolean,
isMyCollection: boolean,
collectionId: string,
@ -28,11 +25,7 @@ type Props = {
collectionHasEdits: boolean,
isBuiltin: boolean,
doToggleShuffleList: (string, boolean) => void,
doToggleLoopList: (string, boolean) => void,
playNextUri: string,
playNextClaim: StreamClaim,
doPlayUri: (string) => void,
doSetPlayingUri: (string) => void,
firstItem: string,
};
@ -49,11 +42,7 @@ function CollectionActions(props: Props) {
collectionHasEdits,
isBuiltin,
doToggleShuffleList,
doToggleLoopList,
playNextUri,
playNextClaim,
doPlayUri,
doSetPlayingUri,
firstItem,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
@ -63,23 +52,23 @@ function CollectionActions(props: Props) {
const webShareable = true; // collections have cost?
const doPlay = React.useCallback(
(uri) => {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
const navigateUrl = formatLbryUrlForWeb(uri) + `?` + collectionParams.toString();
push(navigateUrl);
doSetPlayingUri(uri);
doPlayUri(uri);
(playUri) => {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: generateListSearchUrlParams(collectionId),
state: { forceAutoplay: true },
});
},
[collectionId, push, doSetPlayingUri, doPlayUri]
[collectionId, push]
);
React.useEffect(() => {
if (playNextClaim && doShuffle) {
if (playNextUri && doShuffle) {
setDoShuffle(false);
doPlay(playNextUri);
}
}, [doPlay, doShuffle, playNextClaim, playNextUri]);
}, [doPlay, doShuffle, playNextUri]);
const lhsSection = (
<>
@ -90,7 +79,6 @@ function CollectionActions(props: Props) {
title={__('Play')}
onClick={() => {
doToggleShuffleList(collectionId, false);
doToggleLoopList(collectionId, false);
doPlay(firstItem);
}}
/>

View file

@ -1,8 +1,8 @@
import { connect } from 'react-redux';
import { doCollectionEdit, makeSelectNameForCollectionId, doCollectionDelete } from 'lbry-redux';
import { makeSelectNameForCollectionId } from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
import CollectionMenuList from './view';
const select = (state, props) => {
@ -17,10 +17,12 @@ const select = (state, props) => {
};
};
export default connect(select, {
doCollectionEdit,
doOpenModal,
doCollectionDelete,
doSetPlayingUri,
doToggleShuffleList,
})(CollectionMenuList);
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToggleShuffleList: (collectionId) => {
dispatch(doToggleLoopList(collectionId, false, true));
dispatch(doToggleShuffleList(undefined, collectionId, true, true));
},
});
export default connect(select, perform)(CollectionMenuList);

View file

@ -7,8 +7,7 @@ import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon';
import * as PAGES from 'constants/pages';
import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
type Props = {
inline?: boolean,
@ -16,34 +15,26 @@ type Props = {
collectionName?: string,
collectionId: string,
playNextUri: string,
doSetPlayingUri: ({ uri: ?string }) => void,
doToggleShuffleList: (string, string, boolean, boolean) => void,
doToggleShuffleList: (string) => void,
};
function CollectionMenuList(props: Props) {
const {
inline = false,
collectionId,
collectionName,
doOpenModal,
playNextUri,
doSetPlayingUri,
doToggleShuffleList,
} = props;
const { inline = false, collectionId, collectionName, doOpenModal, playNextUri, doToggleShuffleList } = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory();
React.useEffect(() => {
if (playNextUri && doShuffle) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
const navigateUrl = formatLbryUrlForWeb(playNextUri) + `?` + collectionParams.toString();
setDoShuffle(false);
doSetPlayingUri({ uri: playNextUri });
push(navigateUrl);
const navigateUrl = formatLbryUrlForWeb(playNextUri);
push({
pathname: navigateUrl,
search: generateListSearchUrlParams(collectionId),
state: { forceAutoplay: true },
});
}
}, [push, doSetPlayingUri, collectionId, playNextUri, doShuffle]);
}, [collectionId, doShuffle, playNextUri, push]);
return (
<Menu>
@ -68,7 +59,7 @@ function CollectionMenuList(props: Props) {
<MenuItem
className="comment__menu-option"
onSelect={() => {
doToggleShuffleList('', collectionId, true, true);
doToggleShuffleList(collectionId);
setDoShuffle(true);
}}
>

View file

@ -7,8 +7,7 @@ import TruncatedText from 'component/common/truncated-text';
import CollectionCount from './collectionCount';
import CollectionPrivate from './collectionPrivate';
import CollectionMenuList from 'component/collectionMenuList';
import { formatLbryUrlForWeb } from 'util/url';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
import FileThumbnail from 'component/fileThumbnail';
type Props = {
@ -62,16 +61,12 @@ function CollectionPreviewTile(props: Props) {
if (collectionId && hasClaim && resolveCollectionItems) {
resolveCollectionItems({ collectionId, page_size: 5 });
}
}, [collectionId, hasClaim]);
}, [collectionId, hasClaim, resolveCollectionItems]);
// const signingChannel = claim && claim.signing_channel;
let navigateUrl = formatLbryUrlForWeb(collectionItemUrls[0] || '/');
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
const navigateUrl =
formatLbryUrlForWeb(collectionItemUrls[0] || '/') + (collectionId ? generateListSearchUrlParams(collectionId) : '');
function handleClick(e) {
if (navigateUrl) {

View file

@ -2440,6 +2440,12 @@ export const icons = {
</svg>
),
[ICONS.PLAY]: buildIcon(<polygon points="5 3 19 12 5 21 5 3" />),
[ICONS.PLAY_PREVIOUS]: buildIcon(
<g>
<polygon points="19 20 9 12 19 4 19 20" />
<line x1="5" y1="19" x2="5" y2="5" />
</g>
),
[ICONS.REPLAY]: buildIcon(
<g>
<polyline points="1 4 1 10 7 10" />

View file

@ -4,6 +4,9 @@ import {
makeSelectTitleForUri,
makeSelectStreamingUrlForUri,
makeSelectClaimIsNsfw,
makeSelectClaimWasPurchased,
makeSelectNextUrlForCollectionAndUrl,
makeSelectPreviousUrlForCollectionAndUrl,
SETTINGS,
} from 'lbry-redux';
import {
@ -13,8 +16,10 @@ import {
makeSelectFileRenderModeForUri,
} from 'redux/selectors/content';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { doSetPlayingUri } from 'redux/actions/content';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { doPlayUri, doSetPlayingUri } from 'redux/actions/content';
import { doFetchRecommendedContent } from 'redux/actions/search';
import { doAnaltyicsPurchaseEvent } from 'redux/actions/app';
import { withRouter } from 'react-router';
import FileRenderFloating from './view';
@ -30,12 +35,16 @@ const select = (state, props) => {
playingUri,
title: makeSelectTitleForUri(uri)(state),
fileInfo: makeSelectFileInfoForUri(uri)(state),
mature: makeSelectClaimIsNsfw(props.uri)(state),
mature: makeSelectClaimIsNsfw(uri)(state),
isFloating: makeSelectIsPlayerFloating(props.location)(state),
streamingUrl: makeSelectStreamingUrlForUri(uri)(state),
floatingPlayerEnabled: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state),
renderMode: makeSelectFileRenderModeForUri(uri)(state),
videoTheaterMode: makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)(state),
costInfo: makeSelectCostInfoForUri(uri)(state),
claimWasPurchased: makeSelectClaimWasPurchased(uri)(state),
nextListUri: collectionId && makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state),
previousListUri: collectionId && makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state),
collectionId,
};
};
@ -43,6 +52,19 @@ const select = (state, props) => {
const perform = (dispatch) => ({
closeFloatingPlayer: () => dispatch(doSetPlayingUri({ uri: null })),
doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)),
doPlayUri: (uri, collectionId, hideFailModal) =>
dispatch(
doPlayUri(
uri,
false,
false,
(fileInfo) => {
dispatch(doAnaltyicsPurchaseEvent(fileInfo));
},
hideFailModal
),
dispatch(doSetPlayingUri({ uri, collectionId }))
),
});
export default withRouter(connect(select, perform)(FileRenderFloating));

View file

@ -11,10 +11,12 @@ import usePersistedState from 'effects/use-persisted-state';
import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
import Draggable from 'react-draggable';
import { onFullscreenChange } from 'util/full-screen';
import { generateListSearchUrlParams, formatLbryUrlForWeb } from 'util/url';
import { useIsMobile } from 'effects/use-screensize';
import debounce from 'util/debounce';
import { useHistory } from 'react-router';
import { isURIEqual, COLLECTIONS_CONSTS } from 'lbry-redux';
import { isURIEqual } from 'lbry-redux';
import AutoplayCountdown from 'component/autoplayCountdown';
const IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false;
const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 60;
@ -34,7 +36,12 @@ type Props = {
primaryUri: ?string,
videoTheaterMode: boolean,
doFetchRecommendedContent: (string, boolean) => void,
doPlayUri: (string, string, boolean) => void,
collectionId: string,
costInfo: any,
claimWasPurchased: boolean,
nextListUri: string,
previousListUri: string,
};
export default function FileRenderFloating(props: Props) {
@ -52,16 +59,23 @@ export default function FileRenderFloating(props: Props) {
primaryUri,
videoTheaterMode,
doFetchRecommendedContent,
doPlayUri,
collectionId,
costInfo,
claimWasPurchased,
nextListUri,
previousListUri,
} = props;
const {
location: { pathname },
} = useHistory();
const { location, push } = useHistory();
const hideFloatingPlayer = location.state && location.state.hideFloatingPlayer;
const isMobile = useIsMobile();
const mainFilePlaying = playingUri && isURIEqual(playingUri.uri, primaryUri);
const [fileViewerRect, setFileViewerRect] = useState();
const [desktopPlayStartTime, setDesktopPlayStartTime] = useState();
const [wasDragging, setWasDragging] = useState(false);
const [doNavigate, setDoNavigate] = useState(false);
const [playNextUrl, setPlayNextUrl] = useState(true);
const [countdownCanceled, setCountdownCanceled] = useState(false);
const [position, setPosition] = usePersistedState('floating-file-viewer:position', {
x: -25,
y: window.innerHeight - 400,
@ -71,13 +85,10 @@ export default function FileRenderFloating(props: Props) {
y: 0,
});
let navigateUrl;
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = uri + `?` + collectionParams.toString();
}
const navigateUrl = uri + (collectionId ? generateListSearchUrlParams(collectionId) : '');
const isFree = costInfo && costInfo.cost === 0;
const canViewFile = isFree || claimWasPurchased;
const playingUriSource = playingUri && playingUri.source;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
const isReadyToPlay = isPlayable && (streamingUrl || (fileInfo && fileInfo.completed));
@ -102,7 +113,7 @@ export default function FileRenderFloating(props: Props) {
}
}
function clampToScreen(pos) {
const clampToScreen = React.useCallback((pos) => {
const ESTIMATED_SCROLL_BAR_PX = 50;
const FLOATING_PLAYER_CLASS = 'content__viewer--floating';
const fpPlayerElem = document.querySelector(`.${FLOATING_PLAYER_CLASS}`);
@ -115,7 +126,7 @@ export default function FileRenderFloating(props: Props) {
pos.y = getScreenHeight() - fpPlayerElem.getBoundingClientRect().height;
}
}
}
}, []);
// Updated 'relativePos' based on persisted 'position':
const stringifiedPosition = JSON.stringify(position);
@ -139,7 +150,7 @@ export default function FileRenderFloating(props: Props) {
setPosition({ x: pos.x, y: pos.y });
}
}
}, [isFloating, stringifiedPosition]);
}, [clampToScreen, isFloating, position.x, position.y, setPosition, stringifiedPosition]);
// Listen to main-window resizing and adjust the fp position accordingly:
useEffect(() => {
@ -157,9 +168,9 @@ export default function FileRenderFloating(props: Props) {
// 'relativePos' is needed in the dependency list to avoid stale closure.
// Otherwise, this could just be changed to a one-time effect.
}, [relativePos]);
}, [clampToScreen, relativePos.x, relativePos.y, setPosition]);
function handleResize() {
const handleResize = React.useCallback(() => {
const element = mainFilePlaying
? document.querySelector(`.${PRIMARY_PLAYER_WRAPPER_CLASS}`)
: document.querySelector(`.${INLINE_PLAYER_WRAPPER_CLASS}`);
@ -184,13 +195,14 @@ export default function FileRenderFloating(props: Props) {
// $FlowFixMe
setFileViewerRect({ ...objectRect, windowOffset: window.pageYOffset });
}
}, [mainFilePlaying]);
useEffect(() => {
if (streamingUrl) {
handleResize();
setCountdownCanceled(false);
}
}, [streamingUrl, pathname, playingUriSource, isFloating, mainFilePlaying]);
}, [handleResize, streamingUrl, videoTheaterMode]);
useEffect(() => {
handleResize();
@ -201,7 +213,7 @@ export default function FileRenderFloating(props: Props) {
window.removeEventListener('resize', handleResize);
onFullscreenChange(window, 'remove', handleResize);
};
}, [setFileViewerRect, isFloating, playingUriSource, mainFilePlaying, videoTheaterMode]);
}, [handleResize]);
useEffect(() => {
// @if TARGET='app'
@ -219,9 +231,42 @@ export default function FileRenderFloating(props: Props) {
if (isFloating) {
doFetchRecommendedContent(uri, mature);
}
}, [uri, mature, isFloating]);
}, [doFetchRecommendedContent, isFloating, mature, uri]);
if (!isPlayable || !uri || (isFloating && (isMobile || !floatingPlayerEnabled))) {
const doPlay = React.useCallback(
(playUri) => {
setDoNavigate(false);
if (!isFloating) {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: collectionId && generateListSearchUrlParams(collectionId),
state: { collectionId, forceAutoplay: true, hideFloatingPlayer: true },
});
} else {
doPlayUri(playUri, collectionId, true);
}
},
[collectionId, doPlayUri, isFloating, push]
);
useEffect(() => {
if (!doNavigate) return;
if (playNextUrl && nextListUri) {
doPlay(nextListUri);
} else if (previousListUri) {
doPlay(previousListUri);
}
setPlayNextUrl(true);
}, [doNavigate, doPlay, nextListUri, playNextUrl, previousListUri]);
if (
!isPlayable ||
!uri ||
(isFloating && (isMobile || !floatingPlayerEnabled || hideFloatingPlayer)) ||
(collectionId && !isFloating && ((!canViewFile && !nextListUri) || countdownCanceled))
) {
return null;
}
@ -307,17 +352,30 @@ export default function FileRenderFloating(props: Props) {
// @endif
/>
) : (
<LoadingScreen status={loadingMessage} />
<>
{collectionId && !canViewFile ? (
<div className="content__loading">
<AutoplayCountdown
nextRecommendedUri={nextListUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => doPlayUri(uri, collectionId, false)}
doPrevious={() => {
setPlayNextUrl(false);
setDoNavigate(true);
}}
onCanceled={() => setCountdownCanceled(true)}
skipPaid
/>
</div>
) : (
<LoadingScreen status={loadingMessage} />
)}
</>
)}
{isFloating && (
<div className="draggable content__info">
<div className="claim-preview__title" title={title || uri}>
<Button
label={title || uri}
navigate={navigateUrl || uri}
button="link"
className="content__floating-link"
/>
<Button label={title || uri} navigate={navigateUrl} button="link" className="content__floating-link" />
</div>
<UriIndicator link uri={uri} />
</div>

View file

@ -37,7 +37,6 @@ const select = (state, props) => {
insufficientCredits: makeSelectInsufficientCreditsForUri(props.uri)(state),
streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state),
hasCostInfo: Boolean(makeSelectCostInfoForUri(props.uri)(state)),
costInfo: makeSelectCostInfoForUri(props.uri)(state),
renderMode: makeSelectFileRenderModeForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),

View file

@ -22,12 +22,11 @@ type Props = {
fileInfo: FileListItem,
uri: string,
history: { push: (string) => void },
location: { search: ?string, pathname: string },
location: { search: ?string, pathname: string, href: string, state: { forceAutoplay: boolean } },
obscurePreview: boolean,
insufficientCredits: boolean,
claimThumbnail?: string,
autoplay: boolean,
hasCostInfo: boolean,
costInfo: any,
inline: boolean,
renderMode: string,
@ -50,7 +49,6 @@ export default function FileRenderInitiator(props: Props) {
location,
claimThumbnail,
renderMode,
hasCostInfo,
costInfo,
claimWasPurchased,
authenticated,
@ -58,24 +56,20 @@ export default function FileRenderInitiator(props: Props) {
collectionId,
} = props;
// force autoplay if a timestamp is present
let autoplay = props.autoplay;
// get current url
const url = window.location.href;
// check if there is a time parameter, if so force autoplay
if (url.indexOf('t=') > -1) {
autoplay = true;
}
// check if there is a time or autoplay parameter, if so force autoplay
const urlTimeParam = location && location.href && location.href.indexOf('t=') > -1;
const forceAutoplayParam = location && location.state && location.state.forceAutoplay;
const autoplay = forceAutoplayParam || urlTimeParam || props.autoplay;
const cost = costInfo && costInfo.cost;
const isFree = hasCostInfo && cost === 0;
const isFree = costInfo && costInfo.cost === 0;
const canViewFile = isFree || claimWasPurchased;
const fileStatus = fileInfo && fileInfo.status;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
const isText = RENDER_MODES.TEXT_MODES.includes(renderMode);
const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder);
const containerRef = React.useRef<any>();
React.useEffect(() => {
useEffect(() => {
if (claimThumbnail) {
setTimeout(() => {
let newThumbnail = claimThumbnail;
@ -96,7 +90,7 @@ export default function FileRenderInitiator(props: Props) {
}
}, 200);
}
}, [claimThumbnail]);
}, [claimThumbnail, thumbnail]);
function doAuthRedirect() {
history.push(`/$/${PAGES.AUTH}?redirect=${encodeURIComponent(location.pathname)}`);
@ -137,19 +131,20 @@ export default function FileRenderInitiator(props: Props) {
useEffect(() => {
const videoOnPage = document.querySelector('video');
if (
(isFree || claimWasPurchased) &&
((autoplay && !videoOnPage && isPlayable) || RENDER_MODES.AUTO_RENDER_MODES.includes(renderMode))
(canViewFile || forceAutoplayParam) &&
((autoplay && (!videoOnPage || forceAutoplayParam) && isPlayable) ||
RENDER_MODES.AUTO_RENDER_MODES.includes(renderMode))
) {
viewFile();
}
}, [autoplay, viewFile, isFree, renderMode, isPlayable, claimWasPurchased]);
}, [autoplay, canViewFile, forceAutoplayParam, isPlayable, renderMode, viewFile]);
/*
once content is playing, let the appropriate <FileRender> take care of it...
but for playables, always render so area can be used to fill with floating player
*/
if (isPlaying && !isPlayable) {
if (isFree || claimWasPurchased) {
if (canViewFile && !collectionId) {
return null;
}
}

View file

@ -57,8 +57,8 @@ class UriIndicator extends React.PureComponent<Props> {
}
const isChannelClaim = claim.value_type === 'channel';
if (!claim.signing_channel && !isChannelClaim) {
const signingChannel = claim.signing_channel && claim.signing_channel.amount;
if (!signingChannel && !isChannelClaim) {
if (hideAnonymous) {
return null;
}

View file

@ -8,7 +8,13 @@ import {
makeSelectNextUrlForCollectionAndUrl,
makeSelectPreviousUrlForCollectionAndUrl,
} from 'lbry-redux';
import { doChangeVolume, doChangeMute, doAnalyticsView, doAnalyticsBuffer } from 'redux/actions/app';
import {
doChangeVolume,
doChangeMute,
doAnalyticsView,
doAnalyticsBuffer,
doAnaltyicsPurchaseEvent,
} from 'redux/actions/app';
import { selectVolume, selectMute } from 'redux/selectors/app';
import { savePosition, clearPosition, doPlayUri, doSetPlayingUri } from 'redux/actions/content';
import {
@ -78,8 +84,19 @@ const perform = (dispatch) => ({
toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()),
toggleAutoplayNext: () => dispatch(toggleAutoplayNext()),
setVideoPlaybackRate: (rate) => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)),
doPlayUri: (uri) => dispatch(doPlayUri(uri)),
doSetPlayingUri: (uri, collectionId) => dispatch(doSetPlayingUri({ uri, collectionId })),
doPlayUri: (uri, collectionId) =>
dispatch(
doPlayUri(
uri,
false,
false,
(fileInfo) => {
dispatch(doAnaltyicsPurchaseEvent(fileInfo));
},
true
),
dispatch(doSetPlayingUri({ uri, collectionId }))
),
});
export default withRouter(connect(select, perform)(VideoViewer));

View file

@ -25,8 +25,7 @@ import I18nMessage from 'component/i18nMessage';
import { useHistory } from 'react-router';
import { getAllIds } from 'util/buildHomepage';
import type { HomepageCat } from 'util/buildHomepage';
import { formatLbryUrlForWeb } from 'util/url';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb, generateListSearchUrlParams } from 'util/url';
const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
const PLAY_TIMEOUT_LIMIT = 2000;
@ -60,8 +59,7 @@ type Props = {
homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean,
isFloating: boolean,
doPlayUri: (string) => void,
doSetPlayingUri: (string, string) => void,
doPlayUri: (string, string) => void,
collectionId: string,
nextRecommendedUri: string,
previousListUri: string,
@ -104,7 +102,6 @@ function VideoViewer(props: Props) {
shareTelemetry,
isFloating,
doPlayUri,
doSetPlayingUri,
collectionId,
nextRecommendedUri,
previousListUri,
@ -183,28 +180,29 @@ function VideoViewer(props: Props) {
const doPlay = useCallback(
(playUri) => {
let navigateUrl = formatLbryUrlForWeb(playUri);
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
clearPosition(playUri);
}
if (!isFloating) {
push(navigateUrl);
}
doPlayUri(playUri);
doSetPlayingUri(playUri, collectionId);
setDoNavigate(false);
if (!isFloating) {
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
search: collectionId && generateListSearchUrlParams(collectionId),
state: { collectionId, forceAutoplay: true, hideFloatingPlayer: true },
});
} else {
doPlayUri(playUri, collectionId);
}
},
[clearPosition, collectionId, doPlayUri, doSetPlayingUri, isFloating, push]
[collectionId, doPlayUri, isFloating, push]
);
useEffect(() => {
if (doNavigate) {
if (playNextUrl) {
if (permanentUrl !== nextRecommendedUri) {
if (nextRecommendedUri) doPlay(nextRecommendedUri);
if (nextRecommendedUri) {
if (collectionId) clearPosition(permanentUrl);
doPlay(nextRecommendedUri);
}
} else {
setReplay(true);
}
@ -224,7 +222,18 @@ function VideoViewer(props: Props) {
setEnded(false);
setPlayNextUrl(true);
}
}, [doNavigate, doPlay, ended, nextRecommendedUri, permanentUrl, playNextUrl, previousListUri, videoNode]);
}, [
clearPosition,
collectionId,
doNavigate,
doPlay,
ended,
nextRecommendedUri,
permanentUrl,
playNextUrl,
previousListUri,
videoNode,
]);
React.useEffect(() => {
if (ended) {
@ -298,12 +307,12 @@ function VideoViewer(props: Props) {
const doPlayNext = () => {
setPlayNextUrl(true);
setDoNavigate(true);
setEnded(true);
};
const doPlayPrevious = () => {
setPlayNextUrl(false);
setDoNavigate(true);
setEnded(true);
};
const onPlayerReady = useCallback((player: Player, videoNode: any) => {
@ -397,7 +406,6 @@ function VideoViewer(props: Props) {
>
{showAutoplayCountdown && (
<AutoplayCountdown
uri={uri}
nextRecommendedUri={nextRecommendedUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => setReplay(true)}

View file

@ -45,6 +45,7 @@ export const WEB = 'Globe';
export const SHARE = 'Share2';
export const EXTERNAL = 'ExternalLink';
export const PLAY = 'Play';
export const PLAY_PREVIOUS = 'Play Previous';
export const FACEBOOK = 'Facebook';
export const TWITTER = 'Twitter';
export const TELEGRAM = 'Telegram';

View file

@ -148,7 +148,8 @@ export function doPlayUri(
uri: string,
skipCostCheck: boolean = false,
saveFileOverride: boolean = false,
cb?: () => void
cb?: () => void,
hideFailModal: boolean = false
) {
return (dispatch: Dispatch, getState: () => any) => {
const state = getState();
@ -183,7 +184,7 @@ export function doPlayUri(
!claimWasPurchased &&
(!instantPurchaseMax || !instantPurchaseEnabled || cost > instantPurchaseMax)
) {
dispatch(doOpenModal(MODALS.AFFIRM_PURCHASE, { uri }));
if (!hideFailModal) dispatch(doOpenModal(MODALS.AFFIRM_PURCHASE, { uri }));
} else {
beginGetFile();
}
@ -290,10 +291,10 @@ export function doToggleLoopList(collectionId: string, loop: boolean, hideToast:
type: ACTIONS.TOGGLE_LOOP_LIST,
data: { collectionId, loop },
});
if (loop && !hideToast) {
if (!hideToast) {
dispatch(
doToast({
message: __('Loop is on.'),
message: loop ? __('Loop is on.') : __('Loop is off.'),
})
);
}
@ -323,18 +324,18 @@ export function doToggleShuffleList(currentUri: string, collectionId: string, sh
type: ACTIONS.TOGGLE_SHUFFLE_LIST,
data: { collectionId, newUrls },
});
if (!hideToast) {
dispatch(
doToast({
message: __('Shuffle is on.'),
})
);
}
} else {
dispatch({
type: ACTIONS.TOGGLE_SHUFFLE_LIST,
data: { collectionId, newUrls: false },
});
}
if (!hideToast) {
dispatch(
doToast({
message: shuffle ? __('Shuffle is on.') : __('Shuffle is off.'),
})
);
}
};
}

View file

@ -432,5 +432,11 @@ export function toggleAutoplayNext() {
const autoplayNext = makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state);
dispatch(doSetClientSetting(SETTINGS.AUTOPLAY_NEXT, !autoplayNext, ready));
dispatch(
doToast({
message: autoplayNext ? __('Autoplay Next is off.') : __('Autoplay Next is on.'),
})
);
};
}

View file

@ -156,3 +156,9 @@ export const generateRssUrl = (domain, channelClaim) => {
const url = `${domain}/$/rss/${channelClaim.canonical_url.replace('lbry://', '').replace('#', ':')}`;
return url;
};
export const generateListSearchUrlParams = (collectionId) => {
const urlParams = new URLSearchParams();
urlParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
return `?` + urlParams.toString();
};