Expanded Playback and List controls (#6921)

* Dont show countdown on Lists

* Add Repeat icon

* Add Shuffle icon

* Add Replay Icon

* Add Replay Option to autoplayCountdown

* Add Loop Control for Lists

* Add Shuffle control for Lists

* Improve View List Link and Fetch action

* Add Play Button to List page

* Add Shuffle Play Option on List Page and Menus

* Fix Modal Remove Collection I18n

* CSS: Fix Large list titles

* Fix List playback on Floating Player

* Add Theater Mode to its own class and fix bar text display

* Add Play Next VJS component

* Add Play Next Button

* Add Play Previous VJS Component

* Add Play Previous Button

* Add Autoplay Next Button

* Add separate control for autoplay next in list

* Bump redux

* Update CHANGELOG.md
This commit is contained in:
saltrafael 2021-09-02 17:05:32 -03:00 committed by GitHub
parent 061e4ddd55
commit 64cbd4ae8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1030 additions and 300 deletions

View file

@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Show on content page if a file is part of a playlist already _community pr!_([#6393](https://github.com/lbryio/lbry-desktop/pull/6393))
- Add filtering to playlists ([#6905](https://github.com/lbryio/lbry-desktop/pull/6905))
- Added direct replying to notifications _community pr!_ ([#6935](https://github.com/lbryio/lbry-desktop/pull/6935))
- Added "Replay" option on autoplay countdown ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added "Loop" option on Lists ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added "Shuffle" option on Lists ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added Play Next/Previous buttons (with shortcuts SHIFT+N/SHIFT+P) ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Added separate control for autoplay next on video player ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
### Changed
- Use Canonical Url for copy link ([#6500](https://github.com/lbryio/lbry-desktop/pull/6500))
@ -23,6 +28,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Use resolve for OG metadata instead of chainquery _community pr!_ ([#6787](https://github.com/lbryio/lbry-desktop/pull/6787))
- Improved clickability of notification links _community pr!_ ([#6711](https://github.com/lbryio/lbry-desktop/pull/6711))
- Changing the supported language from Filipino to Tagalog _community pr!_ ([#6951](https://github.com/lbryio/lbry-desktop/pull/6951))
- Don't show countdown to next item in list ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
- Changed "View List" popup option to link, so can be opened on a new tab ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
### Fixed
- App now supports '#' and ':' for claimId separator ([#6496](https://github.com/lbryio/lbry-desktop/pull/6496))
@ -42,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix OG: "Unparsable data structure - Truncated Unicode character" _community pr!_ ([#6839](https://github.com/lbryio/lbry-desktop/pull/6839))
- Fix Paid embed warning overlay redirection button now links to odysee _community pr!_ ([#6819](https://github.com/lbryio/lbry-desktop/pull/6819))
- Fix comment section redirection to create channel _community pr!_ ([#6557](https://github.com/lbryio/lbry-desktop/pull/6557))
- Clicking on the title of a floating player will take you back to the list ([#6921](https://github.com/lbryio/lbry-desktop/pull/6921))
## [0.51.1] - [2021-06-26]

View file

@ -152,7 +152,7 @@
"imagesloaded": "^4.1.4",
"json-loader": "^0.5.4",
"lbry-format": "https://github.com/lbryio/lbry-format.git",
"lbry-redux": "lbryio/lbry-redux#dc264ec50ca3208d940521bdac0b61352d913c95",
"lbry-redux": "lbryio/lbry-redux#12a2ffc708bed45ba8d5a46620dc3892aaf890f8",
"lbryinc": "lbryio/lbryinc#8f9a58bfc8312a65614fd7327661cdcc502c4e59",
"lint-staged": "^7.0.2",
"localforage": "^1.7.1",

View file

@ -1,9 +1,6 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, SETTINGS, COLLECTIONS_CONSTS, makeSelectNextUrlForCollectionAndUrl } from 'lbry-redux';
import { makeSelectClaimForUri } from 'lbry-redux';
import { withRouter } from 'react-router';
import { makeSelectIsPlayerFloating, makeSelectNextUnplayedRecommended } from 'redux/selectors/content';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { doSetPlayingUri, doPlayUri, clearPosition } from 'redux/actions/content';
import AutoplayCountdown from './view';
import { selectModal } from 'redux/selectors/app';
@ -11,33 +8,9 @@ import { selectModal } from 'redux/selectors/app';
AutoplayCountdown does not fetch it's own next content to play, it relies on <RecommendedContent> being rendered.
This is dumb but I'm just the guy who noticed -kj
*/
const select = (state, props) => {
const { location } = props;
const { search } = location;
const urlParams = new URLSearchParams(search);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
const select = (state, props) => ({
nextRecommendedClaim: makeSelectClaimForUri(props.nextRecommendedUri)(state),
modal: selectModal(state),
});
let nextRecommendedUri;
if (collectionId) {
nextRecommendedUri = makeSelectNextUrlForCollectionAndUrl(collectionId, props.uri)(state);
} else {
nextRecommendedUri = makeSelectNextUnplayedRecommended(props.uri)(state);
}
return {
collectionId,
nextRecommendedUri,
nextRecommendedClaim: makeSelectClaimForUri(nextRecommendedUri)(state),
isFloating: makeSelectIsPlayerFloating(props.location)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state),
modal: selectModal(state),
};
};
export default withRouter(
connect(select, {
doSetPlayingUri,
doPlayUri,
clearPosition,
})(AutoplayCountdown)
);
export default withRouter(connect(select, null)(AutoplayCountdown));

View file

@ -1,12 +1,12 @@
// @flow
import React, { useCallback } from 'react';
import React from 'react';
import Button from 'component/button';
import UriIndicator from 'component/uriIndicator';
import I18nMessage from 'component/i18nMessage';
import { formatLbryUrlForWeb } from 'util/url';
import { withRouter } from 'react-router';
import debounce from 'util/debounce';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import * as ICONS from 'constants/icons';
const DEBOUNCE_SCROLL_HANDLER_MS = 150;
const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown';
@ -14,25 +14,19 @@ type Props = {
history: { push: (string) => void },
nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string,
isFloating: boolean,
doSetPlayingUri: ({ uri: ?string }) => void,
doPlayUri: (string) => void,
modal: { id: string, modalProps: {} },
collectionId?: string,
clearPosition: (string) => void,
doNavigate: () => void,
doReplay: () => void,
};
function AutoplayCountdown(props: Props) {
const {
nextRecommendedUri,
nextRecommendedClaim,
doSetPlayingUri,
doPlayUri,
isFloating,
history: { push },
modal,
collectionId,
clearPosition,
doNavigate,
doReplay,
} = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -45,40 +39,6 @@ function AutoplayCountdown(props: Props) {
const anyModalPresent = modal !== undefined && modal !== null;
const isTimerPaused = timerPaused || anyModalPresent;
let navigateUrl;
if (nextTitle) {
navigateUrl = formatLbryUrlForWeb(nextRecommendedUri);
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
}
const doPlay = useCallback(
(uri) => {
if (collectionId) {
clearPosition(uri);
}
doSetPlayingUri({ uri });
doPlayUri(uri);
},
[clearPosition, doSetPlayingUri, doPlayUri]
);
const doNavigate = useCallback(() => {
if (!isFloating) {
if (navigateUrl) {
push(navigateUrl);
doPlay(nextRecommendedUri);
}
} else {
if (nextRecommendedUri) {
doPlay(nextRecommendedUri);
}
}
}, [navigateUrl, nextRecommendedUri, isFloating, doPlay, push]);
function shouldPauseAutoplay() {
const elm = document.querySelector(`.${CLASSNAME_AUTOPLAY_COUNTDOWN}`);
return elm && elm.getBoundingClientRect().top < 0;
@ -118,7 +78,7 @@ function AutoplayCountdown(props: Props) {
return () => {
clearInterval(interval);
};
}, [timer, doNavigate, navigateUrl, push, timerCanceled, isTimerPaused, nextRecommendedUri]);
}, [timer, doNavigate, push, timerCanceled, isTimerPaused, nextRecommendedUri]);
if (timerCanceled || !nextRecommendedUri) {
return null;
@ -149,6 +109,15 @@ function AutoplayCountdown(props: Props) {
<Button label={__('Cancel')} button="link" onClick={() => setTimerCanceled(true)} />
</div>
)}
<Button
label={__('Replay?')}
button="link"
iconRight={ICONS.REPLAY}
onClick={() => {
setTimerCanceled(true);
doReplay();
}}
/>
</div>
</div>
</div>

View file

@ -9,6 +9,8 @@ import {
COLLECTIONS_CONSTS,
makeSelectEditedCollectionForId,
makeSelectClaimIsMine,
doFetchItemsInCollection,
makeSelectUrlsForCollectionId,
} from 'lbry-redux';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doChannelMute, doChannelUnmute } from 'redux/actions/blocked';
@ -28,16 +30,23 @@ import { doToast } from 'redux/actions/notifications';
import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
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 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;
const contentPermanentUri = contentClaim && contentClaim.permanent_url;
const contentChannelUri = (contentSigningChannel && contentSigningChannel.permanent_url) || contentPermanentUri;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
return {
claim,
@ -60,10 +69,12 @@ const select = (state, props) => {
isSubscribed: makeSelectIsSubscribed(contentChannelUri, true)(state),
channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state),
isAdmin: selectHasAdminChannel(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(props.collectionId, contentPermanentUri)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state),
editedCollection: makeSelectEditedCollectionForId(props.collectionId)(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(collectionId, contentPermanentUri)(state),
isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
resolvedList,
playNextUri,
};
};
@ -90,6 +101,9 @@ const perform = (dispatch) => ({
doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)),
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)),
});
export default connect(select, perform)(ClaimPreview);

View file

@ -7,7 +7,7 @@ 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 } from 'util/url';
import { generateShareUrl, generateRssUrl, generateLbryContentUrl, formatLbryUrlForWeb } from 'util/url';
import { useHistory } from 'react-router';
import { buildURI, parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
@ -56,6 +56,11 @@ type Props = {
isChannelPage: boolean,
editedCollection: Collection,
isAuthenticated: boolean,
playNextUri: string,
resolvedList: boolean,
fetchCollectionItems: (string) => void,
doSetPlayingUri: (string) => void,
doToggleShuffleList: (string) => void,
};
function ClaimMenuList(props: Props) {
@ -93,7 +98,13 @@ function ClaimMenuList(props: Props) {
isChannelPage = false,
editedCollection,
isAuthenticated,
playNextUri,
resolvedList,
fetchCollectionItems,
doSetPlayingUri,
doToggleShuffleList,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@');
const isChannel = !incognitoClaim && !contentSigningChannel;
const { channelName } = parseURI(contentChannelUri);
@ -107,6 +118,27 @@ function ClaimMenuList(props: Props) {
: __('Follow');
const { push, replace } = useHistory();
const fetchItems = React.useCallback(() => {
if (collectionId) {
fetchCollectionItems(collectionId);
}
}, [collectionId, fetchCollectionItems]);
React.useEffect(() => {
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);
}
}
}, [collectionId, doSetPlayingUri, doShuffle, doToggleShuffleList, playNextUri, push, resolvedList]);
if (!claim) {
return null;
}
@ -246,9 +278,21 @@ function ClaimMenuList(props: Props) {
{collectionId && isCollectionClaim ? (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<div className="menu__link">
<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 && (

View file

@ -10,21 +10,50 @@ import {
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 CollectionActions from './view';
const select = (state, props) => ({
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(props.collectionId)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(props.collectionId)(state)),
});
const select = (state, props) => {
let firstItem;
const collectionUrls = props.collectionUrls;
if (collectionUrls) {
// this will help play the first valid claim in a list
// in case the first urls have been deleted
collectionUrls.map((url) => {
const claim = makeSelectClaimForUri(url)(state);
if (firstItem === undefined && claim) {
firstItem = claim.permanent_url;
}
});
}
const collectionId = props.collectionId;
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)),
});
export default connect(select, perform)(CollectionActions);

View file

@ -11,6 +11,8 @@ 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';
type Props = {
uri: string,
@ -24,6 +26,14 @@ type Props = {
showInfo: boolean,
setShowInfo: (boolean) => void,
collectionHasEdits: boolean,
isBuiltin: boolean,
doToggleShuffleList: (string, boolean) => void,
doToggleLoopList: (string, boolean) => void,
playNextUri: string,
playNextClaim: StreamClaim,
doPlayUri: (string) => void,
doSetPlayingUri: (string) => void,
firstItem: string,
};
function CollectionActions(props: Props) {
@ -37,61 +47,115 @@ function CollectionActions(props: Props) {
showInfo,
setShowInfo,
collectionHasEdits,
isBuiltin,
doToggleShuffleList,
doToggleLoopList,
playNextUri,
playNextClaim,
doPlayUri,
doSetPlayingUri,
firstItem,
} = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory();
const isMobile = useIsMobile();
const claimId = claim && claim.claim_id;
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);
},
[collectionId, push, doSetPlayingUri, doPlayUri]
);
React.useEffect(() => {
if (playNextClaim && doShuffle) {
setDoShuffle(false);
doPlay(playNextUri);
}
}, [doPlay, doShuffle, playNextClaim, playNextUri]);
const lhsSection = (
<>
{ENABLE_FILE_REACTIONS && uri && <FileReactions uri={uri} />}
{uri && <ClaimSupportButton uri={uri} fileAction />}
{/* TODO Add ClaimRepostButton component */}
{uri && (
<Button
className="button--file-action"
icon={ICONS.SHARE}
label={__('Share')}
title={__('Share')}
onClick={() => openModal(MODALS.SOCIAL_SHARE, { uri, webShareable })}
/>
<Button
className="button--file-action"
icon={ICONS.PLAY}
label={__('Play')}
title={__('Play')}
onClick={() => {
doToggleShuffleList(collectionId, false);
doToggleLoopList(collectionId, false);
doPlay(firstItem);
}}
/>
<Button
className="button--file-action"
icon={ICONS.SHUFFLE}
label={__('Shuffle Play')}
title={__('Shuffle Play')}
onClick={() => {
doToggleShuffleList(collectionId, true);
setDoShuffle(true);
}}
/>
{!isBuiltin && (
<>
{ENABLE_FILE_REACTIONS && uri && <FileReactions uri={uri} />}
{uri && <ClaimSupportButton uri={uri} fileAction />}
{/* TODO Add ClaimRepostButton component */}
{uri && (
<Button
className="button--file-action"
icon={ICONS.SHARE}
label={__('Share')}
title={__('Share')}
onClick={() => openModal(MODALS.SOCIAL_SHARE, { uri, webShareable })}
/>
)}
</>
)}
</>
);
const rhsSection = (
<>
{isMyCollection && (
<Button
title={uri ? __('Update') : __('Publish')}
label={uri ? __('Update') : __('Publish')}
className={classnames('button--file-action')}
onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
icon={ICONS.PUBLISH}
iconColor={collectionHasEdits && 'red'}
iconSize={18}
disabled={claimIsPending}
/>
)}
{isMyCollection && (
<Button
className={classnames('button--file-action')}
title={__('Delete List')}
onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })}
icon={ICONS.DELETE}
iconSize={18}
description={__('Delete List')}
disabled={claimIsPending}
/>
)}
{!isMyCollection && (
<Button
title={__('Report content')}
className="button--file-action"
icon={ICONS.REPORT}
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
/>
)}
{!isBuiltin &&
(isMyCollection ? (
<>
<Button
title={uri ? __('Update') : __('Publish')}
label={uri ? __('Update') : __('Publish')}
className={classnames('button--file-action')}
onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
icon={ICONS.PUBLISH}
iconColor={collectionHasEdits && 'red'}
iconSize={18}
disabled={claimIsPending}
/>
<Button
className={classnames('button--file-action')}
title={__('Delete List')}
onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })}
icon={ICONS.DELETE}
iconSize={18}
description={__('Delete List')}
disabled={claimIsPending}
/>
</>
) : (
<Button
title={__('Report content')}
className="button--file-action"
icon={ICONS.REPORT}
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
/>
))}
</>
);

View file

@ -7,15 +7,18 @@ import {
makeSelectClaimForUri,
makeSelectClaimIsMine,
} from 'lbry-redux';
import {
selectPlayingUri,
} from 'redux/selectors/content';
import { selectPlayingUri, selectListLoop, selectListShuffle } from 'redux/selectors/content';
import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
const select = (state, props) => {
const playingUri = selectPlayingUri(state);
const playingUrl = playingUri && playingUri.uri;
const claim = makeSelectClaimForUri(playingUrl)(state);
const url = claim && claim.permanent_url;
const loopList = selectListLoop(state);
const loop = loopList && loopList.collectionId === props.id && loopList.loop;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === props.id && shuffleList.newUrls;
return {
url,
@ -23,7 +26,12 @@ const select = (state, props) => {
collectionUrls: makeSelectUrlsForCollectionId(props.id)(state),
collectionName: makeSelectNameForCollectionId(props.id)(state),
isMine: makeSelectClaimIsMine(url)(state),
loop,
shuffle,
};
};
export default connect(select)(CollectionContent);
export default connect(select, {
doToggleLoopList,
doToggleShuffleList,
})(CollectionContent);

View file

@ -15,23 +15,52 @@ type Props = {
collectionUrls: Array<Claim>,
collectionName: string,
collection: any,
loop: boolean,
shuffle: boolean,
doToggleLoopList: (string, boolean) => void,
doToggleShuffleList: (string, string, boolean) => void,
createUnpublishedCollection: (string, Array<any>, ?string) => void,
};
export default function CollectionContent(props: Props) {
const { collectionUrls, collectionName, id, url } = props;
const { collectionUrls, collectionName, id, url, loop, shuffle, doToggleLoopList, doToggleShuffleList } = props;
return (
<Card
isBodyList
className="file-page__recommended"
className="file-page__recommended-collection"
title={
<span>
<Icon
icon={(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) || ICONS.STACK}
className="icon--margin-right" />
{collectionName}
</span>
<>
<span className="file-page__recommended-collection__row">
<Icon
icon={
(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
ICONS.STACK
}
className="icon--margin-right"
/>
{collectionName}
</span>
<span className="file-page__recommended-collection__row">
<Button
button="alt"
title="Loop"
icon={ICONS.REPEAT}
iconColor={loop && 'blue'}
className="button--file-action"
onClick={() => doToggleLoopList(id, !loop)}
/>
<Button
button="alt"
title="Shuffle"
icon={ICONS.SHUFFLE}
iconColor={shuffle && 'blue'}
className="button--file-action"
onClick={() => doToggleShuffleList(url, id, !shuffle)}
/>
</span>
</>
}
titleActions={
<div className="card__title-actions--link">

View file

@ -1,11 +1,19 @@
import { connect } from 'react-redux';
import { doCollectionEdit, makeSelectNameForCollectionId, doCollectionDelete } from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import CollectionMenuList from './view';
const select = (state, props) => {
const collectionId = props.collectionId;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
return {
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
playNextUri,
};
};
@ -13,4 +21,6 @@ export default connect(select, {
doCollectionEdit,
doOpenModal,
doCollectionDelete,
doSetPlayingUri,
doToggleShuffleList,
})(CollectionMenuList);

View file

@ -7,19 +7,44 @@ 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';
type Props = {
inline?: boolean,
doOpenModal: (string, {}) => void,
collectionName?: string,
collectionId: string,
playNextUri: string,
doSetPlayingUri: ({ uri: ?string }) => void,
doToggleShuffleList: (string, string, boolean, boolean) => void,
};
function CollectionMenuList(props: Props) {
const { inline = false, collectionId, collectionName, doOpenModal } = props;
const {
inline = false,
collectionId,
collectionName,
doOpenModal,
playNextUri,
doSetPlayingUri,
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);
}
}, [push, doSetPlayingUri, collectionId, playNextUri, doShuffle]);
return (
<Menu>
<MenuButton
@ -35,9 +60,21 @@ function CollectionMenuList(props: Props) {
{collectionId && collectionName && (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<div className="menu__link">
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')}
</a>
</MenuItem>
<MenuItem
className="comment__menu-option"
onSelect={() => {
doToggleShuffleList('', collectionId, true, true);
setDoShuffle(true);
}}
>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.SHUFFLE} />
{__('Shuffle Play')}
</div>
</MenuItem>
<MenuItem

View file

@ -2439,4 +2439,28 @@ export const icons = {
/>
</svg>
),
[ICONS.PLAY]: buildIcon(<polygon points="5 3 19 12 5 21 5 3" />),
[ICONS.REPLAY]: buildIcon(
<g>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</g>
),
[ICONS.REPEAT]: buildIcon(
<g>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</g>
),
[ICONS.SHUFFLE]: buildIcon(
<g>
<polyline points="16 3 21 3 21 8" />
<line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" />
<line x1="15" y1="15" x2="21" y2="21" />
<line x1="4" y1="4" x2="9" y2="9" />
</g>
),
};

View file

@ -12,7 +12,7 @@ import { makeSelectFileRenderModeForUri, makeSelectFileExtensionForUri } from 'r
import FileRender from './view';
const select = (state, props) => {
const autoplay = props.embedded ? false : makeSelectClientSetting(SETTINGS.AUTOPLAY)(state);
const autoplay = props.embedded ? false : makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state);
return {
currentTheme: makeSelectClientSetting(SETTINGS.THEME)(state),
claim: makeSelectClaimForUri(props.uri)(state),

View file

@ -22,6 +22,7 @@ const select = (state, props) => {
const playingUri = selectPlayingUri(state);
const primaryUri = selectPrimaryUri(state);
const uri = playingUri && playingUri.uri;
const collectionId = playingUri && playingUri.collectionId;
return {
uri,
@ -35,6 +36,7 @@ const select = (state, props) => {
floatingPlayerEnabled: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state),
renderMode: makeSelectFileRenderModeForUri(uri)(state),
videoTheaterMode: makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)(state),
collectionId,
};
};

View file

@ -14,7 +14,7 @@ import { onFullscreenChange } from 'util/full-screen';
import { useIsMobile } from 'effects/use-screensize';
import debounce from 'util/debounce';
import { useHistory } from 'react-router';
import { isURIEqual } from 'lbry-redux';
import { isURIEqual, COLLECTIONS_CONSTS } from 'lbry-redux';
const IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false;
const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 60;
@ -34,6 +34,7 @@ type Props = {
primaryUri: ?string,
videoTheaterMode: boolean,
doFetchRecommendedContent: (string, boolean) => void,
collectionId: string,
};
export default function FileRenderFloating(props: Props) {
@ -51,6 +52,7 @@ export default function FileRenderFloating(props: Props) {
primaryUri,
videoTheaterMode,
doFetchRecommendedContent,
collectionId,
} = props;
const {
location: { pathname },
@ -69,6 +71,13 @@ 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 playingUriSource = playingUri && playingUri.source;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
const isReadyToPlay = isPlayable && (streamingUrl || (fileInfo && fileInfo.completed));
@ -303,7 +312,12 @@ export default function FileRenderFloating(props: Props) {
{isFloating && (
<div className="draggable content__info">
<div className="claim-preview__title" title={title || uri}>
<Button label={title || uri} navigate={uri} button="link" className="content__floating-link" />
<Button
label={title || uri}
navigate={navigateUrl || uri}
button="link"
className="content__floating-link"
/>
</div>
<UriIndicator link uri={uri} />
</div>

View file

@ -7,6 +7,7 @@ import {
makeSelectStreamingUrlForUri,
makeSelectClaimWasPurchased,
SETTINGS,
COLLECTIONS_CONSTS,
} from 'lbry-redux';
import { makeSelectCostInfoForUri } from 'lbryinc';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -22,27 +23,34 @@ import {
import FileRenderInitiator from './view';
import { doAnaltyicsPurchaseEvent } from 'redux/actions/app';
const select = (state, props) => ({
claimThumbnail: makeSelectThumbnailForUri(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
obscurePreview: makeSelectShouldObscurePreview(props.uri)(state),
isPlaying: makeSelectIsPlaying(props.uri)(state),
playingUri: selectPlayingUri(state),
insufficientCredits: makeSelectInsufficientCreditsForUri(props.uri)(state),
streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state),
hasCostInfo: Boolean(makeSelectCostInfoForUri(props.uri)(state)),
costInfo: makeSelectCostInfoForUri(props.uri)(state),
renderMode: makeSelectFileRenderModeForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
claimWasPurchased: makeSelectClaimWasPurchased(props.uri)(state),
authenticated: selectUserVerifiedEmail(state),
});
const select = (state, props) => {
const { search } = props.location;
const urlParams = new URLSearchParams(search);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
return {
claimThumbnail: makeSelectThumbnailForUri(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
obscurePreview: makeSelectShouldObscurePreview(props.uri)(state),
isPlaying: makeSelectIsPlaying(props.uri)(state),
playingUri: selectPlayingUri(state),
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),
claimWasPurchased: makeSelectClaimWasPurchased(props.uri)(state),
authenticated: selectUserVerifiedEmail(state),
collectionId,
};
};
const perform = (dispatch) => ({
play: (uri) => {
play: (uri, collectionId) => {
dispatch(doSetPrimaryUri(uri));
dispatch(doSetPlayingUri({ uri }));
dispatch(doSetPlayingUri({ uri, collectionId }));
dispatch(doPlayUri(uri, undefined, undefined, (fileInfo) => dispatch(doAnaltyicsPurchaseEvent(fileInfo))));
},
});

View file

@ -17,7 +17,7 @@ import FileRenderPlaceholder from 'static/img/fileRenderPlaceholder.png';
const SPACE_BAR_KEYCODE = 32;
type Props = {
play: (string) => void,
play: (string, string) => void,
isLoading: boolean,
isPlaying: boolean,
fileInfo: FileListItem,
@ -36,6 +36,7 @@ type Props = {
claimWasPurchased: boolean,
authenticated: boolean,
videoTheaterMode: boolean,
collectionId: string,
};
export default function FileRenderInitiator(props: Props) {
@ -55,6 +56,7 @@ export default function FileRenderInitiator(props: Props) {
claimWasPurchased,
authenticated,
videoTheaterMode,
collectionId,
} = props;
// force autoplay if a timestamp is present
@ -109,9 +111,9 @@ export default function FileRenderInitiator(props: Props) {
e.stopPropagation();
}
play(uri);
play(uri, collectionId);
},
[play, uri]
[play, uri, collectionId]
);
useEffect(() => {

View file

@ -11,7 +11,8 @@ import SettingContent from './view';
const select = (state) => ({
isAuthenticated: selectUserVerifiedEmail(state),
floatingPlayer: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state),
autoplayMedia: makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state),
autoplayNext: makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state),
hideReposts: makeSelectClientSetting(SETTINGS.HIDE_REPOSTS)(state),
showNsfw: selectShowMatureContent(state),
myChannelUrls: selectMyChannelUrls(state),

View file

@ -22,7 +22,8 @@ type Props = {
// --- select ---
isAuthenticated: boolean,
floatingPlayer: boolean,
autoplay: boolean,
autoplayMedia: boolean,
autoplayNext: boolean,
hideReposts: ?boolean,
showNsfw: boolean,
myChannelUrls: ?Array<string>,
@ -39,7 +40,8 @@ export default function SettingContent(props: Props) {
const {
isAuthenticated,
floatingPlayer,
autoplay,
autoplayMedia,
autoplayNext,
hideReposts,
showNsfw,
myChannelUrls,
@ -73,12 +75,21 @@ export default function SettingContent(props: Props) {
/>
</SettingsRow>
<SettingsRow title={__('Autoplay media files')} subtitle={__(HELP.AUTOPLAY)}>
<SettingsRow title={__('Autoplay media files')} subtitle={__(HELP.AUTOPLAY_MEDIA)}>
<FormField
type="checkbox"
name="autoplay"
onChange={() => setClientSetting(SETTINGS.AUTOPLAY, !autoplay)}
checked={autoplay}
name="autoplay media"
onChange={() => setClientSetting(SETTINGS.AUTOPLAY_MEDIA, !autoplayMedia)}
checked={autoplayMedia}
/>
</SettingsRow>
<SettingsRow title={__('Autoplay next recommended content')} subtitle={__(HELP.AUTOPLAY_NEXT)}>
<FormField
type="checkbox"
name="autoplay next"
onChange={() => setClientSetting(SETTINGS.AUTOPLAY_NEXT, !autoplayNext)}
checked={autoplayNext}
/>
</SettingsRow>
@ -208,7 +219,8 @@ export default function SettingContent(props: Props) {
// prettier-ignore
const HELP = {
FLOATING_PLAYER: 'Keep content playing in the corner when navigating to a different page.',
AUTOPLAY: 'Autoplay video and audio files when navigating to a file, as well as the next related item when a file finishes playing.',
AUTOPLAY_MEDIA: 'Autoplay video and audio files when navigating to a file.',
AUTOPLAY_NEXT: 'Autoplay video and audio files as the next related item when a file finishes playing.',
HIDE_REPOSTS: 'You will not see reposts by people you follow or receive email notifying about them.',
SHOW_MATURE: '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. ',
MAX_PURCHASE_PRICE: 'This will prevent you from purchasing any content over a certain cost, as a safety measure.',

View file

@ -1,38 +1,69 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, makeSelectFileInfoForUri, makeSelectThumbnailForUri, SETTINGS } from 'lbry-redux';
import {
makeSelectClaimForUri,
makeSelectFileInfoForUri,
makeSelectThumbnailForUri,
SETTINGS,
COLLECTIONS_CONSTS,
makeSelectNextUrlForCollectionAndUrl,
makeSelectPreviousUrlForCollectionAndUrl,
} from 'lbry-redux';
import { doChangeVolume, doChangeMute, doAnalyticsView, doAnalyticsBuffer } from 'redux/actions/app';
import { selectVolume, selectMute } from 'redux/selectors/app';
import { savePosition, clearPosition } from 'redux/actions/content';
import { makeSelectContentPositionForUri } from 'redux/selectors/content';
import { savePosition, clearPosition, doPlayUri, doSetPlayingUri } from 'redux/actions/content';
import {
makeSelectContentPositionForUri,
makeSelectIsPlayerFloating,
makeSelectNextUnplayedRecommended,
selectPlayingUri,
} from 'redux/selectors/content';
import VideoViewer from './view';
import { withRouter } from 'react-router';
import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards';
import { selectDaemonSettings, makeSelectClientSetting, selectHomepageData } from 'redux/selectors/settings';
import { toggleVideoTheaterMode, doSetClientSetting } from 'redux/actions/settings';
import { toggleVideoTheaterMode, toggleAutoplayNext, doSetClientSetting } from 'redux/actions/settings';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user';
const select = (state, props) => {
const { search } = props.location;
const urlParams = new URLSearchParams(search);
const autoplay = urlParams.get('autoplay');
const uri = props.uri;
// TODO: eventually this should be received from DB and not local state (https://github.com/lbryio/lbry-desktop/issues/6796)
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(props.uri)(state);
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(uri)(state);
const userId = selectUser(state) && selectUser(state).id;
const playingUri = selectPlayingUri(state);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID) || (playingUri && playingUri.collectionId);
let nextRecommendedUri;
let previousListUri;
if (collectionId) {
nextRecommendedUri = makeSelectNextUrlForCollectionAndUrl(collectionId, uri)(state);
previousListUri = makeSelectPreviousUrlForCollectionAndUrl(collectionId, uri)(state);
} else {
nextRecommendedUri = makeSelectNextUnplayedRecommended(uri)(state);
}
return {
autoplayIfEmbedded: Boolean(autoplay),
autoplaySetting: Boolean(makeSelectClientSetting(SETTINGS.AUTOPLAY)(state)),
autoplayMedia: Boolean(makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state)),
autoplayNext: Boolean(makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state)),
volume: selectVolume(state),
muted: selectMute(state),
videoPlaybackRate: makeSelectClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE)(state),
position: position,
hasFileInfo: Boolean(makeSelectFileInfoForUri(props.uri)(state)),
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
hasFileInfo: Boolean(makeSelectFileInfoForUri(uri)(state)),
thumbnail: makeSelectThumbnailForUri(uri)(state),
claim: makeSelectClaimForUri(uri)(state),
homepageData: selectHomepageData(state),
authenticated: selectUserVerifiedEmail(state),
userId: userId,
shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data,
isFloating: makeSelectIsPlayerFloating(props.location)(state),
collectionId,
nextRecommendedUri,
previousListUri,
videoTheaterMode: makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)(state),
};
};
@ -45,7 +76,10 @@ const perform = (dispatch) => ({
doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)),
claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()),
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 })),
});
export default withRouter(connect(select, perform)(VideoViewer));

View file

@ -0,0 +1,33 @@
// @flow
import type { Player } from './videojs';
import videojs from 'video.js';
class AutoplayNextButton extends videojs.getComponent('Button') {
constructor(player, options = {}, autoplayNext) {
super(player, options, autoplayNext);
const title = autoplayNext ? 'Autoplay Next On' : 'Autoplay Next Off';
this.controlText(title);
this.setAttribute('aria-label', title);
this.addClass('vjs-button--autoplay-next');
this.setAttribute('aria-checked', autoplayNext);
}
}
export function addAutoplayNextButton(player: Player, toggleAutoplayNext: () => void, autoplayNext: boolean) {
const controlBar = player.getChild('controlBar');
const autoplayButton = new AutoplayNextButton(
player,
{
name: 'AutoplayNextButton',
text: 'Autoplay Next',
clickHandler: () => {
toggleAutoplayNext();
},
},
autoplayNext
);
controlBar.addChild(autoplayButton);
}

View file

@ -0,0 +1,25 @@
// @flow
import type { Player } from './videojs';
import videojs from 'video.js';
class PlayNextButton extends videojs.getComponent('Button') {
constructor(player, options = {}) {
super(player, options);
this.addClass('vjs-button--play-next');
this.controlText('Play Next');
}
}
export function addPlayNextButton(player: Player, playNextURI: () => void) {
const controlBar = player.getChild('controlBar');
const playNext = new PlayNextButton(player, {
name: 'PlayNextButton',
text: 'Play Next',
clickHandler: () => {
playNextURI();
},
});
controlBar.addChild(playNext, {}, 1);
}

View file

@ -0,0 +1,25 @@
// @flow
import type { Player } from './videojs';
import videojs from 'video.js';
class PlayPreviousButton extends videojs.getComponent('Button') {
constructor(player, options = {}) {
super(player, options);
this.addClass('vjs-button--play-previous');
this.controlText('Play Previous');
}
}
export function addPlayPreviousButton(player: Player, playPreviousURI: () => void) {
const controlBar = player.getChild('controlBar');
const playPrevious = new PlayPreviousButton(player, {
name: 'PlayPreviousButton',
text: 'Play Previous',
clickHandler: () => {
playPreviousURI();
},
});
controlBar.addChild(playPrevious, {}, 0);
}

View file

@ -1,16 +1,25 @@
// @flow
import type { Player } from './videojs';
import videojs from 'video.js';
class TheaterModeButton extends videojs.getComponent('Button') {
constructor(player, options = {}) {
super(player, options);
this.addClass('vjs-button--theater-mode');
this.controlText('Theater Mode');
}
}
export function addTheaterModeButton(player: Player, toggleVideoTheaterMode: () => void) {
var myButton = player.controlBar.addChild('button', {
text: __('Theater mode'),
const controlBar = player.getChild('controlBar');
const theaterMode = new TheaterModeButton(player, {
name: 'TheaterModeButton',
text: 'Theater mode',
clickHandler: () => {
toggleVideoTheaterMode();
},
});
// $FlowFixMe
myButton.addClass('vjs-button--theater-mode');
// $FlowFixMe
myButton.setAttribute('title', __('Theater mode'));
controlBar.addChild(theaterMode);
}

View file

@ -49,16 +49,21 @@ type Props = {
source: string,
sourceType: string,
poster: ?string,
onPlayerReady: (Player) => void,
onPlayerReady: (Player, any) => void,
isAudio: boolean,
startMuted: boolean,
autoplay: boolean,
autoplaySetting: boolean,
toggleVideoTheaterMode: () => void,
adUrl: ?string,
claimId: ?string,
userId: ?number,
// allowPreRoll: ?boolean,
shareTelemetry: boolean,
replay: boolean,
videoTheaterMode: boolean,
playNext: () => void,
playPrevious: () => void,
};
// type VideoJSOptions = {
@ -103,6 +108,9 @@ const SMALL_J_KEYCODE = 74;
const SMALL_K_KEYCODE = 75;
const SMALL_L_KEYCODE = 76;
const P_KEYCODE = 80;
const N_KEYCODE = 78;
const FULLSCREEN_KEYCODE = SMALL_F_KEYCODE;
const MUTE_KEYCODE = SMALL_M_KEYCODE;
const THEATER_MODE_KEYCODE = SMALL_T_KEYCODE;
@ -185,6 +193,7 @@ properties for this component should be kept to ONLY those that if changed shoul
export default React.memo<Props>(function VideoJs(props: Props) {
const {
autoplay,
autoplaySetting,
startMuted,
source,
sourceType,
@ -197,6 +206,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
userId,
// allowPreRoll,
shareTelemetry,
replay,
videoTheaterMode,
playNext,
playPrevious,
} = props;
const [reload, setReload] = useState('initial');
@ -298,43 +311,41 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (player) {
try {
const controlBar = player.getChild('controlBar');
switch (e.type) {
case 'play':
controlBar.getChild('PlayToggle').controlText(__('Pause (space)'));
break;
case 'pause':
controlBar.getChild('PlayToggle').controlText(__('Play (space)'));
break;
case 'volumechange':
controlBar
.getChild('VolumePanel')
.getChild('MuteToggle')
.controlText(player.muted() || player.volume() === 0 ? __('Unmute (m)') : __('Mute (m)'));
break;
case 'fullscreenchange':
controlBar
.getChild('FullscreenToggle')
.controlText(player.isFullscreen() ? __('Exit Fullscreen (f)') : __('Fullscreen (f)'));
break;
case 'loadstart':
// --- Do everything ---
controlBar.getChild('PlaybackRateMenuButton').controlText(__('Playback Rate (<, >)'));
controlBar.getChild('QualityButton').controlText(__('Quality'));
resolveCtrlText({ type: 'play' });
resolveCtrlText({ type: 'pause' });
resolveCtrlText({ type: 'volumechange' });
resolveCtrlText({ type: 'fullscreenchange' });
// (1) The 'Theater mode' button should probably be changed to a class
// so that we can use getChild() with a specific name. There might be
// clashes if we add a new button in the future.
// (2) We'll have to get 'makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)'
// as a prop here so we can say "Theater mode|Default mode" instead of
// "Toggle Theater mode".
controlBar.getChild('Button').controlText(__('Toggle Theater mode (t)'));
break;
default:
if (isDev) throw Error('Unexpected: ' + e.type);
break;
switch (e.type) {
case 'play':
controlBar.getChild('PlayToggle').controlText(__('Pause (space)'));
break;
case 'pause':
controlBar.getChild('PlayToggle').controlText(__('Play (space)'));
break;
case 'volumechange':
controlBar
.getChild('VolumePanel')
.getChild('MuteToggle')
.controlText(player.muted() || player.volume() === 0 ? __('Unmute (m)') : __('Mute (m)'));
break;
case 'fullscreenchange':
controlBar
.getChild('FullscreenToggle')
.controlText(player.isFullscreen() ? __('Exit Fullscreen (f)') : __('Fullscreen (f)'));
break;
case 'loadstart':
// --- Do everything ---
controlBar.getChild('PlaybackRateMenuButton').controlText(__('Playback Rate (<, >)'));
controlBar.getChild('QualityButton').controlText(__('Quality'));
resolveCtrlText({ type: 'play' });
resolveCtrlText({ type: 'pause' });
resolveCtrlText({ type: 'volumechange' });
resolveCtrlText({ type: 'fullscreenchange' });
controlBar
.getChild('TheaterModeButton')
.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
controlBar.getChild('PlayNextButton').controlText(__('Play Next (SHIFT+N)'));
controlBar.getChild('PlayPreviousButton').controlText(__('Play Previous (SHIFT+P)'));
break;
default:
if (isDev) throw Error('Unexpected: ' + e.type);
break;
}
} catch {
// Just fail silently. It'll just be due to hidden ctrls, and if it is
@ -392,6 +403,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (e.altKey || e.ctrlKey || e.metaKey || !e.shiftKey) return;
if (e.keyCode === PERIOD_KEYCODE) changePlaybackSpeed(true);
if (e.keyCode === COMMA_KEYCODE) changePlaybackSpeed(false);
if (e.keyCode === N_KEYCODE) playNext();
if (e.keyCode === P_KEYCODE) playPrevious();
}
function handleSingleKeyActions(e: KeyboardEvent) {
@ -585,7 +598,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
player.children_[0].setAttribute('playsinline', '');
// I think this is a callback function
onPlayerReady(player);
const videoNode = containerRef.current && containerRef.current.querySelector('video, audio');
onPlayerReady(player, videoNode);
});
// pre-roll ads
@ -606,7 +620,41 @@ export default React.memo<Props>(function VideoJs(props: Props) {
return vjs;
}
// This lifecycle hook is only called once (on mount), or when `isAudio` changes.
useEffect(() => {
const player = playerRef.current;
if (replay && player) {
player.play();
}
}, [replay]);
useEffect(() => {
const player = playerRef.current;
if (player) {
const controlBar = player.getChild('controlBar');
controlBar
.getChild('TheaterModeButton')
.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
}
}, [videoTheaterMode]);
useEffect(() => {
const player = playerRef.current;
if (player) {
const touchOverlay = player.getChild('TouchOverlay');
const controlBar = player.getChild('controlBar') || touchOverlay.getChild('controlBar');
const autoplayButton = controlBar.getChild('AutoplayNextButton');
if (autoplayButton) {
const title = autoplaySetting ? 'Autoplay Next On' : 'Autoplay Next Off';
autoplayButton.controlText(title);
autoplayButton.setAttribute('aria-label', title);
autoplayButton.setAttribute('aria-checked', autoplaySetting);
}
}
}, [autoplaySetting]);
// This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes.
useEffect(() => {
const vjsElement = createVideoPlayerDOM(containerRef.current);
@ -635,7 +683,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
window.player = undefined;
}
};
}, [isAudio]);
}, [isAudio, source]);
// Update video player and reload when source URL changes
useEffect(() => {

View file

@ -16,12 +16,17 @@ import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded';
import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle';
import LoadingScreen from 'component/common/loading-screen';
import { addTheaterModeButton } from './internal/theater-mode';
import { addAutoplayNextButton } from './internal/autoplay-next';
import { addPlayNextButton } from './internal/play-next';
import { addPlayPreviousButton } from './internal/play-previous';
import { useGetAds } from 'effects/use-get-ads';
import Button from 'component/button';
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';
const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
const PLAY_TIMEOUT_LIMIT = 2000;
@ -38,7 +43,8 @@ type Props = {
videoPlaybackRate: number,
volume: number,
uri: string,
autoplaySetting: boolean,
autoplayMedia: boolean,
autoplayNext: boolean,
autoplayIfEmbedded: boolean,
desktopPlayStartTime?: number,
doAnalyticsView: (string, number) => Promise<any>,
@ -47,11 +53,19 @@ type Props = {
savePosition: (string, number) => void,
clearPosition: (string) => void,
toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void,
authenticated: boolean,
userId: number,
homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean,
isFloating: boolean,
doPlayUri: (string) => void,
doSetPlayingUri: (string, string) => void,
collectionId: string,
nextRecommendedUri: string,
previousListUri: string,
videoTheaterMode: boolean,
};
/*
@ -72,7 +86,8 @@ function VideoViewer(props: Props) {
uri,
muted,
volume,
autoplaySetting,
autoplayMedia,
autoplayNext,
autoplayIfEmbedded,
doAnalyticsView,
doAnalyticsBuffer,
@ -81,22 +96,34 @@ function VideoViewer(props: Props) {
clearPosition,
desktopPlayStartTime,
toggleVideoTheaterMode,
toggleAutoplayNext,
setVideoPlaybackRate,
homepageData,
authenticated,
userId,
shareTelemetry,
isFloating,
doPlayUri,
doSetPlayingUri,
collectionId,
nextRecommendedUri,
previousListUri,
videoTheaterMode,
} = props;
const permanentUrl = claim && claim.permanent_url;
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
const claimId = claim && claim.claim_id;
const channelClaimId = claim && claim.signing_channel && claim.signing_channel.claim_id;
const isAudio = contentType.includes('audio');
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
const {
push,
location: { pathname },
} = useHistory();
const [doNavigate, setDoNavigate] = useState(false);
const [playNextUrl, setPlayNextUrl] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false);
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const [isEndededEmbed, setIsEndededEmbed] = useState(false);
const vjsCallbackDataRef: any = React.useRef();
@ -108,6 +135,8 @@ function VideoViewer(props: Props) {
/* isLoading was designed to show loading screen on first play press, rather than completely black screen, but
breaks because some browsers (e.g. Firefox) block autoplay but leave the player.play Promise pending */
const [isLoading, setIsLoading] = useState(false);
const [replay, setReplay] = useState(false);
const [videoNode, setVideoNode] = useState();
// force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true)
useEffect(() => {
@ -152,28 +181,92 @@ function VideoViewer(props: Props) {
});
}
const onEnded = React.useCallback(() => {
analytics.videoIsPlaying(false);
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);
},
[clearPosition, collectionId, doPlayUri, doSetPlayingUri, isFloating, push]
);
if (adUrl) {
setAdUrl(null);
return;
useEffect(() => {
if (doNavigate) {
if (playNextUrl) {
if (permanentUrl !== nextRecommendedUri) {
if (nextRecommendedUri) doPlay(nextRecommendedUri);
} else {
setReplay(true);
}
} else {
if (videoNode) {
const currentTime = videoNode.currentTime;
if (currentTime <= 5) {
if (previousListUri && permanentUrl !== previousListUri) doPlay(previousListUri);
} else {
videoNode.currentTime = 0;
}
setDoNavigate(false);
}
}
if (!ended) setDoNavigate(false);
setEnded(false);
setPlayNextUrl(true);
}
}, [doNavigate, doPlay, ended, nextRecommendedUri, permanentUrl, playNextUrl, previousListUri, videoNode]);
if (embedded) {
setIsEndededEmbed(true);
} else if (autoplaySetting) {
setShowAutoplayCountdown(true);
React.useEffect(() => {
if (ended) {
analytics.videoIsPlaying(false);
if (adUrl) {
setAdUrl(null);
return;
}
if (embedded) {
setIsEndededEmbed(true);
} else if (!collectionId && autoplayNext) {
setShowAutoplayCountdown(true);
} else if (collectionId) {
setDoNavigate(true);
}
clearPosition(uri);
}
clearPosition(uri);
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl, clearPosition, uri]);
}, [
embedded,
setIsEndededEmbed,
autoplayMedia,
setShowAutoplayCountdown,
adUrl,
setAdUrl,
clearPosition,
uri,
ended,
collectionId,
autoplayNext,
]);
function onPlay(player) {
setEnded(false);
setIsLoading(false);
setIsPlaying(true);
setShowAutoplayCountdown(false);
setIsEndededEmbed(false);
setReplay(false);
setDoNavigate(false);
analytics.videoIsPlaying(true, player);
}
@ -203,12 +296,29 @@ function VideoViewer(props: Props) {
playerReadyDependencyList.push(desktopPlayStartTime);
}
const onPlayerReady = useCallback((player: Player) => {
const doPlayNext = () => {
setPlayNextUrl(true);
setDoNavigate(true);
};
const doPlayPrevious = () => {
setPlayNextUrl(false);
setDoNavigate(true);
};
const onPlayerReady = useCallback((player: Player, videoNode: any) => {
if (!embedded) {
setVideoNode(videoNode);
player.muted(muted);
player.volume(volume);
player.playbackRate(videoPlaybackRate);
addTheaterModeButton(player, toggleVideoTheaterMode);
if (collectionId) {
addPlayNextButton(player, doPlayNext);
addPlayPreviousButton(player, doPlayPrevious);
} else {
addAutoplayNextButton(player, toggleAutoplayNext, autoplayNext);
}
}
const shouldPlay = !embedded || autoplayIfEmbedded;
@ -244,7 +354,7 @@ function VideoViewer(props: Props) {
// first play tracking, used for initializing the watchman api
player.on('tracking:firstplay', doTrackingFirstPlay);
player.on('ended', onEnded);
player.on('ended', () => setEnded(true));
player.on('play', onPlay);
player.on('pause', (event) => onPause(event, player));
player.on('dispose', (event) => onDispose(event, player));
@ -285,7 +395,14 @@ function VideoViewer(props: Props) {
})}
onContextMenu={stopContextMenu}
>
{showAutoplayCountdown && <AutoplayCountdown uri={uri} />}
{showAutoplayCountdown && (
<AutoplayCountdown
uri={uri}
nextRecommendedUri={nextRecommendedUri}
doNavigate={() => setDoNavigate(true)}
doReplay={() => setReplay(true)}
/>
)}
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{/* disable this loading behavior because it breaks when player.play() promise hangs */}
@ -332,10 +449,15 @@ function VideoViewer(props: Props) {
startMuted={autoplayIfEmbedded}
toggleVideoTheaterMode={toggleVideoTheaterMode}
autoplay={!embedded || autoplayIfEmbedded}
autoplaySetting={autoplayNext}
claimId={claimId}
userId={userId}
allowPreRoll={!embedded && !authenticated}
shareTelemetry={shareTelemetry}
replay={replay}
videoTheaterMode={videoTheaterMode}
playNext={doPlayNext}
playPrevious={doPlayPrevious}
/>
)}
</div>

View file

@ -100,6 +100,8 @@ export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI';
export const CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED';
export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED';
export const TOGGLE_LOOP_LIST = 'TOGGLE_LOOP_LIST';
export const TOGGLE_SHUFFLE_LIST = 'TOGGLE_SHUFFLE_LIST';
// Files
export const FILE_LIST_STARTED = 'FILE_LIST_STARTED';

View file

@ -170,3 +170,6 @@ export const STAR = 'star';
export const MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod';
export const BADGE_STREAMER = 'BadgeStreamer';
export const REPLAY = 'Replay';
export const REPEAT = 'Repeat';
export const SHUFFLE = 'Shuffle';

View file

@ -14,7 +14,8 @@ export const THEME = 'theme';
export const THEMES = 'themes';
export const AUTOMATIC_DARK_MODE_ENABLED = 'automatic_dark_mode_enabled';
export const CLOCK_24H = 'clock_24h';
export const AUTOPLAY = 'autoplay';
export const AUTOPLAY_MEDIA = 'autoplay';
export const AUTOPLAY_NEXT = 'autoplay';
export const OS_NOTIFICATIONS_ENABLED = 'os_notifications_enabled';
export const AUTO_DOWNLOAD = 'auto_download';
export const AUTO_LAUNCH = 'auto_launch';

View file

@ -36,7 +36,7 @@ function ModalRemoveCollection(props: Props) {
</React.Fragment>
) : (
<I18nMessage tokens={{ title: <cite>{uri && title ? `"${title}"` : `"${collectionName}"`}</cite> }}>
Are you sure you'd like to remove "%title%"?
Are you sure you'd like to remove %title%?
</I18nMessage>
)
}

View file

@ -70,13 +70,12 @@ export default function CollectionPage(props: Props) {
const urlsReady =
collectionUrls && (totalItems === undefined || (totalItems && totalItems === collectionUrls.length));
const shouldFetch = !claim && !collection;
React.useEffect(() => {
if (collectionId && !urlsReady && !didTryResolve && shouldFetch) {
if (collectionId && !urlsReady && !didTryResolve && !collection) {
fetchCollectionItems(collectionId, () => setDidTryResolve(true));
}
}, [collectionId, urlsReady, didTryResolve, shouldFetch, setDidTryResolve, fetchCollectionItems]);
}, [collectionId, urlsReady, didTryResolve, setDidTryResolve, fetchCollectionItems, collection]);
const pending = (
<div className="help card__title--help">
@ -112,18 +111,27 @@ export default function CollectionPage(props: Props) {
title={
<span>
<Icon
icon={(collectionId === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(collectionId === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) || ICONS.STACK}
className="icon--margin-right" />
icon={
(collectionId === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
(collectionId === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
ICONS.STACK
}
className="icon--margin-right"
/>
{claim ? claim.value.title || claim.name : collection && collection.name}
</span>
}
titleActions={titleActions}
subtitle={subTitle}
body={
!isBuiltin && (
<CollectionActions uri={uri} collectionId={collectionId} setShowInfo={setShowInfo} showInfo={showInfo} />
)
<CollectionActions
uri={uri}
collectionId={collectionId}
setShowInfo={setShowInfo}
showInfo={showInfo}
isBuiltin={isBuiltin}
collectionUrls={collectionUrls}
/>
}
actions={
showInfo &&

View file

@ -16,6 +16,8 @@ import {
makeSelectClaimForUri,
makeSelectClaimIsMine,
makeSelectClaimWasPurchased,
doToast,
makeSelectUrlsForCollectionId,
} from 'lbry-redux';
import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc';
import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
@ -110,16 +112,18 @@ export function doSetPlayingUri({
source,
pathname,
commentId,
collectionId,
}: {
uri: ?string,
source?: string,
commentId?: string,
pathname: string,
collectionId: string,
}) {
return (dispatch: Dispatch) => {
dispatch({
type: ACTIONS.SET_PLAYING_URI,
data: { uri, source, pathname, commentId },
data: { uri, source, pathname, commentId, collectionId },
});
};
}
@ -279,3 +283,58 @@ export const doRecommendationClicked = (claimId: string, index: number) => (disp
});
}
};
export function doToggleLoopList(collectionId: string, loop: boolean, hideToast: boolean) {
return (dispatch: Dispatch) => {
dispatch({
type: ACTIONS.TOGGLE_LOOP_LIST,
data: { collectionId, loop },
});
if (loop && !hideToast) {
dispatch(
doToast({
message: __('Loop is on.'),
})
);
}
};
}
export function doToggleShuffleList(currentUri: string, collectionId: string, shuffle: boolean, hideToast: boolean) {
return (dispatch: Dispatch, getState: () => any) => {
if (shuffle) {
const state = getState();
const urls = makeSelectUrlsForCollectionId(collectionId)(state);
let newUrls = urls
.map((item) => ({ item, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ item }) => item);
// the currently playing URI should be first in list or else
// can get in strange position where it might be in the middle or last
// and the shuffled list ends before scrolling through all entries
if (currentUri && currentUri !== '') {
newUrls.splice(newUrls.indexOf(currentUri), 1);
newUrls.splice(0, 0, currentUri);
}
dispatch({
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 },
});
}
};
}

View file

@ -18,8 +18,8 @@ export const IS_MAC = process.platform === 'darwin';
const UPDATE_IS_NIGHT_INTERVAL = 5 * 60 * 1000;
export function doFetchDaemonSettings() {
return dispatch => {
Lbry.settings_get().then(settings => {
return (dispatch) => {
Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data);
dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -32,11 +32,11 @@ export function doFetchDaemonSettings() {
}
export function doFindFFmpeg() {
return dispatch => {
return (dispatch) => {
dispatch({
type: LOCAL_ACTIONS.FINDING_FFMPEG_STARTED,
});
return Lbry.ffmpeg_find().then(done => {
return Lbry.ffmpeg_find().then((done) => {
dispatch(doGetDaemonStatus());
dispatch({
type: LOCAL_ACTIONS.FINDING_FFMPEG_COMPLETED,
@ -46,8 +46,8 @@ export function doFindFFmpeg() {
}
export function doGetDaemonStatus() {
return dispatch => {
return Lbry.status().then(status => {
return (dispatch) => {
return Lbry.status().then((status) => {
dispatch({
type: ACTIONS.DAEMON_STATUS_RECEIVED,
data: {
@ -72,7 +72,7 @@ export function doClearDaemonSetting(key) {
key,
};
// not if syncLocked
Lbry.settings_clear(clearKey).then(defaultSettings => {
Lbry.settings_clear(clearKey).then((defaultSettings) => {
if (SDK_SYNC_KEYS.includes(key)) {
dispatch({
type: ACTIONS.SHARED_PREFERENCE_SET,
@ -83,7 +83,7 @@ export function doClearDaemonSetting(key) {
dispatch(doWalletReconnect());
}
});
Lbry.settings_get().then(settings => {
Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data);
dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -108,7 +108,7 @@ export function doSetDaemonSetting(key, value, doNotDispatch = false) {
key,
value: !value && value !== false ? null : value,
};
Lbry.settings_set(newSettings).then(newSetting => {
Lbry.settings_set(newSettings).then((newSetting) => {
if (SDK_SYNC_KEYS.includes(key) && !doNotDispatch) {
dispatch({
type: ACTIONS.SHARED_PREFERENCE_SET,
@ -121,7 +121,7 @@ export function doSetDaemonSetting(key, value, doNotDispatch = false) {
// todo: add sdk reloadsettings() (or it happens automagically?)
}
});
Lbry.settings_get().then(settings => {
Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data);
dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -170,7 +170,7 @@ export function doUpdateIsNight() {
}
export function doUpdateIsNightAsync() {
return dispatch => {
return (dispatch) => {
dispatch(doUpdateIsNight());
setInterval(() => dispatch(doUpdateIsNight()), UPDATE_IS_NIGHT_INTERVAL);
@ -201,8 +201,8 @@ export function doSetDarkTime(value, options) {
export function doGetWalletSyncPreference() {
const SYNC_KEY = 'enable-sync';
return dispatch => {
return Lbry.preference_get({ key: SYNC_KEY }).then(result => {
return (dispatch) => {
return Lbry.preference_get({ key: SYNC_KEY }).then((result) => {
const enabled = result && result[SYNC_KEY];
if (enabled !== null) {
dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled));
@ -214,8 +214,8 @@ export function doGetWalletSyncPreference() {
export function doSetWalletSyncPreference(pref) {
const SYNC_KEY = 'enable-sync';
return dispatch => {
return Lbry.preference_set({ key: SYNC_KEY, value: pref }).then(result => {
return (dispatch) => {
return Lbry.preference_set({ key: SYNC_KEY, value: pref }).then((result) => {
const enabled = result && result[SYNC_KEY];
if (enabled !== null) {
dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled));
@ -226,7 +226,7 @@ export function doSetWalletSyncPreference(pref) {
}
export function doPushSettingsToPrefs() {
return dispatch => {
return (dispatch) => {
return new Promise((resolve, reject) => {
dispatch({
type: LOCAL_ACTIONS.SYNC_CLIENT_SETTINGS,
@ -274,8 +274,8 @@ export function doFetchLanguage(language) {
if (settings.language !== language || (settings.loadedLanguages && !settings.loadedLanguages.includes(language))) {
// this should match the behavior/logic in index-web.html
fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json')
.then(r => r.json())
.then(j => {
.then((r) => r.json())
.then((j) => {
window.i18n_messages[language] = j;
dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_SUCCESS,
@ -284,7 +284,7 @@ export function doFetchLanguage(language) {
},
});
})
.catch(e => {
.catch((e) => {
dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_FAILURE,
});
@ -324,8 +324,8 @@ export function doSetLanguage(language) {
) {
// this should match the behavior/logic in index-web.html
fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json')
.then(r => r.json())
.then(j => {
.then((r) => r.json())
.then((j) => {
window.i18n_messages[language] = j;
dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_SUCCESS,
@ -344,7 +344,7 @@ export function doSetLanguage(language) {
});
}
})
.catch(e => {
.catch((e) => {
window.localStorage.setItem(SETTINGS.LANGUAGE, DEFAULT_LANGUAGE);
dispatch(doSetClientSetting(SETTINGS.LANGUAGE, DEFAULT_LANGUAGE));
const languageName = SUPPORTED_LANGUAGES[language] ? SUPPORTED_LANGUAGES[language] : language;
@ -369,7 +369,7 @@ export function doSetAutoLaunch(value) {
}
if (value === undefined) {
launcher.isEnabled().then(isEnabled => {
launcher.isEnabled().then((isEnabled) => {
if (isEnabled) {
if (!autoLaunch) {
launcher.disable().then(() => {
@ -385,7 +385,7 @@ export function doSetAutoLaunch(value) {
}
});
} else if (value === true) {
launcher.isEnabled().then(function(isEnabled) {
launcher.isEnabled().then(function (isEnabled) {
if (!isEnabled) {
launcher.enable().then(() => {
dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, true));
@ -396,7 +396,7 @@ export function doSetAutoLaunch(value) {
});
} else {
// value = false
launcher.isEnabled().then(function(isEnabled) {
launcher.isEnabled().then(function (isEnabled) {
if (isEnabled) {
launcher.disable().then(() => {
dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, false));
@ -410,7 +410,7 @@ export function doSetAutoLaunch(value) {
}
export function doSetAppToTrayWhenClosed(value) {
return dispatch => {
return (dispatch) => {
window.localStorage.setItem(SETTINGS.TO_TRAY_WHEN_CLOSED, value);
dispatch(doSetClientSetting(SETTINGS.TO_TRAY_WHEN_CLOSED, value));
};
@ -424,3 +424,12 @@ export function toggleVideoTheaterMode() {
dispatch(doSetClientSetting(SETTINGS.VIDEO_THEATER_MODE, !videoTheaterMode));
};
}
export function toggleAutoplayNext() {
return (dispatch, getState) => {
const state = getState();
const autoplayNext = makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state);
dispatch(doSetClientSetting(SETTINGS.AUTOPLAY_NEXT, !autoplayNext));
};
}

View file

@ -25,10 +25,27 @@ reducers[ACTIONS.SET_PLAYING_URI] = (state, action) =>
source: action.data.source,
pathname: action.data.pathname,
commentId: action.data.commentId,
collectionId: action.data.collectionId,
primaryUri: state.primaryUri,
},
});
reducers[ACTIONS.TOGGLE_LOOP_LIST] = (state, action) =>
Object.assign({}, state, {
loopList: {
collectionId: action.data.collectionId,
loop: action.data.loop,
},
});
reducers[ACTIONS.TOGGLE_SHUFFLE_LIST] = (state, action) =>
Object.assign({}, state, {
shuffleList: {
collectionId: action.data.collectionId,
newUrls: action.data.newUrls,
},
});
reducers[ACTIONS.SET_CONTENT_POSITION] = (state, action) => {
const { claimId, outpoint, position } = action.data;
return {

View file

@ -69,7 +69,7 @@ const defaultState = {
// Content
[SETTINGS.SHOW_MATURE]: false,
[SETTINGS.AUTOPLAY]: true,
[SETTINGS.AUTOPLAY_MEDIA]: true,
[SETTINGS.AUTOPLAY_NEXT]: true,
[SETTINGS.FLOATING_PLAYER]: true,
[SETTINGS.AUTO_DOWNLOAD]: true,

View file

@ -29,6 +29,9 @@ export const selectState = (state: any) => state.content || {};
export const selectPlayingUri = createSelector(selectState, (state) => state.playingUri);
export const selectPrimaryUri = createSelector(selectState, (state) => state.primaryUri);
export const selectListLoop = createSelector(selectState, (state) => state.loopList);
export const selectListShuffle = createSelector(selectState, (state) => state.shuffleList);
export const makeSelectIsPlaying = (uri: string) =>
createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri);

View file

@ -132,6 +132,71 @@
}
}
.vjs-button--play-next.vjs-button,
.vjs-button--play-previous.vjs-button,
.vjs-button--autoplay-next.vjs-button {
display: block;
background-repeat: no-repeat;
background-position: center;
&:focus:not(:focus-visible) {
display: block;
background-repeat: no-repeat;
background-position: center;
}
}
.vjs-button--play-next.vjs-button {
order: 0;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='18' height='14' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-skip-forward'%3e%3cpolygon points='5 4 15 12 5 20 5 4'%3e%3c/polygon%3e%3cline x1='19' y1='5' x2='19' y2='19'%3e%3c/line%3e%3c/svg%3e");
&:focus:not(:focus-visible) {
order: 0;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='18' height='14' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-skip-forward'%3e%3cpolygon points='5 4 15 12 5 20 5 4'%3e%3c/polygon%3e%3cline x1='19' y1='5' x2='19' y2='19'%3e%3c/line%3e%3c/svg%3e");
}
}
.vjs-button--play-previous.vjs-button {
order: 0;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='18' height='14' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-skip-back'%3e%3cpolygon points='19 20 9 12 19 4 19 20'%3e%3c/polygon%3e%3cline x1='5' y1='19' x2='5' y2='5'%3e%3c/line%3e%3c/svg%3e");
&:focus:not(:focus-visible) {
order: 0;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='18' height='14' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-skip-back'%3e%3cpolygon points='19 20 9 12 19 4 19 20'%3e%3c/polygon%3e%3cline x1='5' y1='19' x2='5' y2='5'%3e%3c/line%3e%3c/svg%3e");
}
}
.vjs-button--autoplay-next.vjs-button {
margin: auto;
order: 1;
width: 24px;
height: 14px;
border-radius: var(--border-radius);
background: var(--color-gray-4);
transition: background 0.2s;
}
.vjs-button--autoplay-next.vjs-button[aria-checked=true] {
background: var(--color-primary);
}
.vjs-button--autoplay-next.vjs-button::after {
content: "";
position: absolute;
top: 0;
left: 0;
height: 14px;
width: 14px;
background: var(--color-white);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
border-radius: var(--border-radius);
transition: transform 0.2s;
}
.vjs-button--autoplay-next.vjs-button[aria-checked=true]::after {
transform: translateX(12px);
}
.button--link {
color: var(--color-link);
transition: color 0.2s;

View file

@ -316,6 +316,7 @@ $thumbnailWidthSmall: 1rem;
font-size: var(--font-xsmall);
.menu__link {
color: var(--color-text);
padding: 0;
}
}

View file

@ -133,6 +133,24 @@
}
}
.file-page__recommended-collection {
@extend .file-page__recommended;
flex-direction: column;
overflow-wrap: break-word;
.file-page__recommended-collection__row {
display: block;
@media (min-width: $breakpoint-medium) {
max-width: 15rem;
}
@media (max-width: $breakpoint-medium) {
max-width: 50rem;
}
}
}
@media (max-width: $breakpoint-medium) {
flex-direction: column;
> :first-child {

View file

@ -10134,9 +10134,9 @@ lazy-val@^1.0.4:
yargs "^13.2.2"
zstd-codec "^0.1.1"
lbry-redux@lbryio/lbry-redux#dc264ec50ca3208d940521bdac0b61352d913c95:
lbry-redux@lbryio/lbry-redux#12a2ffc708bed45ba8d5a46620dc3892aaf890f8:
version "0.0.1"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/dc264ec50ca3208d940521bdac0b61352d913c95"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/12a2ffc708bed45ba8d5a46620dc3892aaf890f8"
dependencies:
"@ungap/from-entries" "^0.2.1"
proxy-polyfill "0.1.6"