Compare commits

...
Sign in to create a new pull request.

20 commits

Author SHA1 Message Date
saltrafael
84c44a2103 Bump redux 2021-08-25 15:27:19 -04:00
saltrafael
809136358b Add Autoplay Next Button 2021-08-25 15:27:17 -04:00
saltrafael
bd64d5c9ea Add Play Previous Button 2021-08-25 15:27:15 -04:00
saltrafael
73722a4f00 Add Play Previous VJS Component 2021-08-25 15:27:13 -04:00
saltrafael
b26255bf53 Add Play Next Button 2021-08-25 15:27:11 -04:00
saltrafael
ec6f9c8a7f Add Play Next VJS component 2021-08-25 15:27:09 -04:00
saltrafael
10087891f4 Add Theater Mode to its own class and fix bar text display 2021-08-25 15:27:07 -04:00
saltrafael
db848fd961 Add Numbered Steps video key events 2021-08-25 15:27:06 -04:00
saltrafael
9869980e6b Fix List playback on Floating Player 2021-08-25 15:27:04 -04:00
saltrafael
7d4cc58def CSS: Fix Large list titles and fix hover on audio gif thumbnails 2021-08-25 15:27:02 -04:00
saltrafael
4ff12294a7 Fix Modal Remove Collection I18n 2021-08-25 15:27:00 -04:00
saltrafael
6ec25b0f71 Add Shuffle Play Option on List Page and Menus 2021-08-25 15:26:59 -04:00
saltrafael
3376986c26 Improve View List link and Menu action 2021-08-25 15:26:57 -04:00
saltrafael
f580f5d536 Add Shuffle control for Lists 2021-08-25 15:26:55 -04:00
saltrafael
fe01c4764c Add Loop Control for Lists 2021-08-25 15:26:53 -04:00
saltrafael
7b70db4ea7 Add Replay Option to autoplayCountdown 2021-08-25 15:26:52 -04:00
saltrafael
bc930ac13b Add Replay Icon 2021-08-25 15:26:50 -04:00
saltrafael
47929419ab Add Shuffle icon 2021-08-25 15:26:48 -04:00
saltrafael
f7556e5653 Add Repeat icon 2021-08-25 15:26:47 -04:00
saltrafael
15aee9eb4e Remove countdown on Lists 2021-08-25 15:26:45 -04:00
36 changed files with 970 additions and 208 deletions

View file

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

View file

@ -1,7 +1,11 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeSelectClaimForUri, SETTINGS, COLLECTIONS_CONSTS, makeSelectNextUrlForCollectionAndUrl } from 'lbry-redux'; import { makeSelectClaimForUri, SETTINGS, COLLECTIONS_CONSTS, makeSelectNextUrlForCollectionAndUrl } from 'lbry-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { makeSelectIsPlayerFloating, makeSelectNextUnplayedRecommended } from 'redux/selectors/content'; import {
makeSelectIsPlayerFloating,
makeSelectNextUnplayedRecommended,
selectPlayingUri,
} from 'redux/selectors/content';
import { makeSelectClientSetting } from 'redux/selectors/settings'; import { makeSelectClientSetting } from 'redux/selectors/settings';
import { doSetPlayingUri, doPlayUri } from 'redux/actions/content'; import { doSetPlayingUri, doPlayUri } from 'redux/actions/content';
import AutoplayCountdown from './view'; import AutoplayCountdown from './view';
@ -15,7 +19,8 @@ const select = (state, props) => {
const { location } = props; const { location } = props;
const { search } = location; const { search } = location;
const urlParams = new URLSearchParams(search); const urlParams = new URLSearchParams(search);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID); const playingUri = selectPlayingUri(state);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID) || (playingUri && playingUri.collectionId);
let nextRecommendedUri; let nextRecommendedUri;
if (collectionId) { if (collectionId) {

View file

@ -7,6 +7,8 @@ import { formatLbryUrlForWeb } from 'util/url';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import { COLLECTIONS_CONSTS } from 'lbry-redux'; import { COLLECTIONS_CONSTS } from 'lbry-redux';
import * as ICONS from 'constants/icons';
const DEBOUNCE_SCROLL_HANDLER_MS = 150; const DEBOUNCE_SCROLL_HANDLER_MS = 150;
const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown'; const CLASSNAME_AUTOPLAY_COUNTDOWN = 'autoplay-countdown';
@ -15,10 +17,11 @@ type Props = {
nextRecommendedClaim: ?StreamClaim, nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string, nextRecommendedUri: string,
isFloating: boolean, isFloating: boolean,
doSetPlayingUri: ({ uri: ?string }) => void, doSetPlayingUri: ({ uri: ?string, collectionId: ?string }) => void,
doPlayUri: (string) => void, doPlayUri: (string) => void,
modal: { id: string, modalProps: {} }, modal: { id: string, modalProps: {} },
collectionId?: string, collectionId?: string,
setReplay: (boolean) => void,
}; };
function AutoplayCountdown(props: Props) { function AutoplayCountdown(props: Props) {
@ -31,6 +34,7 @@ function AutoplayCountdown(props: Props) {
history: { push }, history: { push },
modal, modal,
collectionId, collectionId,
setReplay,
} = props; } = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title; const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -54,19 +58,14 @@ function AutoplayCountdown(props: Props) {
} }
const doNavigate = useCallback(() => { const doNavigate = useCallback(() => {
if (!isFloating) { if (!isFloating && navigateUrl) {
if (navigateUrl) { push(navigateUrl);
push(navigateUrl);
doSetPlayingUri({ uri: nextRecommendedUri });
doPlayUri(nextRecommendedUri);
}
} else {
if (nextRecommendedUri) {
doSetPlayingUri({ uri: nextRecommendedUri });
doPlayUri(nextRecommendedUri);
}
} }
}, [navigateUrl, nextRecommendedUri, isFloating, doSetPlayingUri, doPlayUri, push]); if (nextRecommendedUri) {
doSetPlayingUri({ uri: nextRecommendedUri, collectionId });
doPlayUri(nextRecommendedUri);
}
}, [isFloating, navigateUrl, nextRecommendedUri, push, doSetPlayingUri, collectionId, doPlayUri]);
function shouldPauseAutoplay() { function shouldPauseAutoplay() {
const elm = document.querySelector(`.${CLASSNAME_AUTOPLAY_COUNTDOWN}`); const elm = document.querySelector(`.${CLASSNAME_AUTOPLAY_COUNTDOWN}`);
@ -88,26 +87,30 @@ function AutoplayCountdown(props: Props) {
// Update countdown timer. // Update countdown timer.
React.useEffect(() => { React.useEffect(() => {
let interval; if (collectionId) {
if (!timerCanceled && nextRecommendedUri) { doNavigate();
if (isTimerPaused) { } else {
clearInterval(interval); let interval;
setTimer(countdownTime); if (!timerCanceled && nextRecommendedUri) {
} else { if (isTimerPaused) {
interval = setInterval(() => { clearInterval(interval);
const newTime = timer - 1; setTimer(countdownTime);
if (newTime === 0) { } else {
doNavigate(); interval = setInterval(() => {
} else { const newTime = timer - 1;
setTimer(timer - 1); if (newTime === 0) {
} doNavigate();
}, 1000); } else {
setTimer(timer - 1);
}
}, 1000);
}
} }
return () => {
clearInterval(interval);
};
} }
return () => { }, [timer, doNavigate, navigateUrl, push, timerCanceled, isTimerPaused, nextRecommendedUri, collectionId]);
clearInterval(interval);
};
}, [timer, doNavigate, navigateUrl, push, timerCanceled, isTimerPaused, nextRecommendedUri]);
if (timerCanceled || !nextRecommendedUri) { if (timerCanceled || !nextRecommendedUri) {
return null; return null;
@ -138,6 +141,15 @@ function AutoplayCountdown(props: Props) {
<Button label={__('Cancel')} button="link" onClick={() => setTimerCanceled(true)} /> <Button label={__('Cancel')} button="link" onClick={() => setTimerCanceled(true)} />
</div> </div>
)} )}
<Button
label={__('Replay?')}
button="link"
iconRight={ICONS.REPLAY}
onClick={() => {
setTimerCanceled(true);
setReplay(true);
}}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,6 +9,8 @@ import {
COLLECTIONS_CONSTS, COLLECTIONS_CONSTS,
makeSelectEditedCollectionForId, makeSelectEditedCollectionForId,
makeSelectClaimIsMine, makeSelectClaimIsMine,
doFetchItemsInCollection,
makeSelectUrlsForCollectionId,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doChannelMute, doChannelUnmute } from 'redux/actions/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 { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import ClaimPreview from './view'; import ClaimPreview from './view';
import fs from 'fs'; import fs from 'fs';
const select = (state, props) => { const select = (state, props) => {
const claim = makeSelectClaimForUri(props.uri, false)(state); const claim = makeSelectClaimForUri(props.uri, false)(state);
const collectionId = props.collectionId;
const resolvedList = makeSelectUrlsForCollectionId(collectionId)(state);
const repostedClaim = claim && claim.reposted_claim; const repostedClaim = claim && claim.reposted_claim;
const contentClaim = repostedClaim || claim; const contentClaim = repostedClaim || claim;
const contentSigningChannel = contentClaim && contentClaim.signing_channel; const contentSigningChannel = contentClaim && contentClaim.signing_channel;
const contentPermanentUri = contentClaim && contentClaim.permanent_url; const contentPermanentUri = contentClaim && contentClaim.permanent_url;
const contentChannelUri = (contentSigningChannel && contentSigningChannel.permanent_url) || contentPermanentUri; 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 { return {
claim, claim,
@ -60,10 +69,12 @@ const select = (state, props) => {
isSubscribed: makeSelectIsSubscribed(contentChannelUri, true)(state), isSubscribed: makeSelectIsSubscribed(contentChannelUri, true)(state),
channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state), channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state),
isAdmin: selectHasAdminChannel(state), isAdmin: selectHasAdminChannel(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(props.collectionId, contentPermanentUri)(state), claimInCollection: makeSelectCollectionForIdHasClaimUrl(collectionId, contentPermanentUri)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state), isMyCollection: makeSelectCollectionIsMine(collectionId)(state),
editedCollection: makeSelectEditedCollectionForId(props.collectionId)(state), editedCollection: makeSelectEditedCollectionForId(collectionId)(state),
isAuthenticated: Boolean(selectUserVerifiedEmail(state)), isAuthenticated: Boolean(selectUserVerifiedEmail(state)),
resolvedList,
playNextUri,
}; };
}; };
@ -90,6 +101,9 @@ const perform = (dispatch) => ({
doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)), doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)),
doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)), doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)),
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)), 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); export default connect(select, perform)(ClaimPreview);

View file

@ -7,7 +7,7 @@ import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button'; import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon'; 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 { useHistory } from 'react-router';
import { buildURI, parseURI, COLLECTIONS_CONSTS } from 'lbry-redux'; import { buildURI, parseURI, COLLECTIONS_CONSTS } from 'lbry-redux';
@ -56,6 +56,11 @@ type Props = {
isChannelPage: boolean, isChannelPage: boolean,
editedCollection: Collection, editedCollection: Collection,
isAuthenticated: boolean, isAuthenticated: boolean,
fetchCollectionItems: (string) => void,
resolvedList: boolean,
playNextUri: string,
doSetPlayingUri: (string) => void,
doToggleShuffleList: (string) => void,
}; };
function ClaimMenuList(props: Props) { function ClaimMenuList(props: Props) {
@ -93,7 +98,13 @@ function ClaimMenuList(props: Props) {
isChannelPage = false, isChannelPage = false,
editedCollection, editedCollection,
isAuthenticated, isAuthenticated,
fetchCollectionItems,
resolvedList,
playNextUri,
doSetPlayingUri,
doToggleShuffleList,
} = props; } = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@'); const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@');
const isChannel = !incognitoClaim && !contentSigningChannel; const isChannel = !incognitoClaim && !contentSigningChannel;
const { channelName } = parseURI(contentChannelUri); const { channelName } = parseURI(contentChannelUri);
@ -107,6 +118,27 @@ function ClaimMenuList(props: Props) {
: __('Follow'); : __('Follow');
const { push, replace } = useHistory(); const { push, replace } = useHistory();
const fetchItems = React.useCallback(() => {
if (collectionId) {
fetchCollectionItems(collectionId);
}
}, [collectionId, fetchCollectionItems]);
React.useEffect(() => {
if (resolvedList && doShuffle) {
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);
}
}
}, [playNextUri, doShuffle, resolvedList, doToggleShuffleList, collectionId, doSetPlayingUri, push]);
if (!claim) { if (!claim) {
return null; return null;
} }
@ -245,10 +277,28 @@ function ClaimMenuList(props: Props) {
{/* COLLECTION OPERATIONS */} {/* COLLECTION OPERATIONS */}
{collectionId && isCollectionClaim ? ( {collectionId && isCollectionClaim ? (
<> <>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}> <MenuItem
<div className="menu__link"> className="comment__menu-option"
onSelect={() => {
if (!resolvedList) fetchItems();
push(`/$/${PAGES.LIST}/${collectionId}`);
}}
>
<a className="menu__link" href={`/$/${PAGES.LIST}/${collectionId}`}>
<Icon aria-hidden icon={ICONS.VIEW} /> <Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')} {__('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> </div>
</MenuItem> </MenuItem>
{isMyCollection && ( {isMyCollection && (

View file

@ -10,21 +10,33 @@ import {
import { makeSelectCostInfoForUri } from 'lbryinc'; import { makeSelectCostInfoForUri } from 'lbryinc';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import CollectionActions from './view'; import CollectionActions from './view';
const select = (state, props) => ({ const select = (state, props) => {
claim: makeSelectClaimForUri(props.uri)(state), const collectionId = props.collectionId;
claimIsMine: makeSelectClaimIsMine(props.uri)(state), const shuffleList = selectListShuffle(state);
costInfo: makeSelectCostInfoForUri(props.uri)(state), const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
myChannels: selectMyChannelClaims(state), const playNextUri = shuffle && shuffle[0];
claimIsPending: makeSelectClaimIsPending(props.uri)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state), return {
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(props.collectionId)(state)), 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)),
playNextUri,
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)), openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)), doToast: (options) => dispatch(doToast(options)),
doSetPlayingUri: (uri) => dispatch(doSetPlayingUri({ uri })),
doToggleShuffleList: (collectionId) => dispatch(doToggleShuffleList(undefined, collectionId, true, true)),
}); });
export default connect(select, perform)(CollectionActions); 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 { EDIT_PAGE, PAGE_VIEW_QUERY } from 'page/collection/view';
import classnames from 'classnames'; import classnames from 'classnames';
import { ENABLE_FILE_REACTIONS } from 'config'; import { ENABLE_FILE_REACTIONS } from 'config';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
import { formatLbryUrlForWeb } from 'util/url';
type Props = { type Props = {
uri: string, uri: string,
@ -24,6 +26,10 @@ type Props = {
showInfo: boolean, showInfo: boolean,
setShowInfo: (boolean) => void, setShowInfo: (boolean) => void,
collectionHasEdits: boolean, collectionHasEdits: boolean,
doToggleShuffleList: (string) => void,
playNextUri: string,
doSetPlayingUri: (string) => void,
isBuiltin: boolean,
}; };
function CollectionActions(props: Props) { function CollectionActions(props: Props) {
@ -37,61 +43,92 @@ function CollectionActions(props: Props) {
showInfo, showInfo,
setShowInfo, setShowInfo,
collectionHasEdits, collectionHasEdits,
doToggleShuffleList,
playNextUri,
doSetPlayingUri,
isBuiltin,
} = props; } = props;
const [doShuffle, setDoShuffle] = React.useState(false);
const { push } = useHistory(); const { push } = useHistory();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const webShareable = true; // collections have cost? const webShareable = true; // collections have cost?
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(playNextUri);
push(navigateUrl);
}
}, [push, doSetPlayingUri, collectionId, playNextUri, doShuffle]);
const lhsSection = ( const lhsSection = (
<> <>
{ENABLE_FILE_REACTIONS && uri && <FileReactions uri={uri} />} <Button
{uri && <ClaimSupportButton uri={uri} fileAction />} className="button--file-action"
{/* TODO Add ClaimRepostButton component */} icon={ICONS.SHUFFLE}
{uri && ( label={__('Shuffle Play')}
<Button title={__('Shuffle Play')}
className="button--file-action" onClick={() => {
icon={ICONS.SHARE} doToggleShuffleList(collectionId);
label={__('Share')} setDoShuffle(true);
title={__('Share')} }}
onClick={() => openModal(MODALS.SOCIAL_SHARE, { uri, webShareable })} />
/> {!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 = ( const rhsSection = (
<> <>
{isMyCollection && ( {!isBuiltin &&
<Button (isMyCollection ? (
title={uri ? __('Update') : __('Publish')} <>
label={uri ? __('Update') : __('Publish')} <Button
className={classnames('button--file-action')} title={uri ? __('Update') : __('Publish')}
onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)} label={uri ? __('Update') : __('Publish')}
icon={ICONS.PUBLISH} className={classnames('button--file-action')}
iconColor={collectionHasEdits && 'red'} onClick={() => push(`?${PAGE_VIEW_QUERY}=${EDIT_PAGE}`)}
iconSize={18} icon={ICONS.PUBLISH}
disabled={claimIsPending} iconColor={collectionHasEdits && 'red'}
/> iconSize={18}
)} disabled={claimIsPending}
{isMyCollection && ( />
<Button <Button
className={classnames('button--file-action')} className={classnames('button--file-action')}
title={__('Delete List')} title={__('Delete List')}
onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })} onClick={() => openModal(MODALS.COLLECTION_DELETE, { uri, collectionId, redirect: `/$/${PAGES.LISTS}` })}
icon={ICONS.DELETE} icon={ICONS.DELETE}
iconSize={18} iconSize={18}
description={__('Delete List')} description={__('Delete List')}
disabled={claimIsPending} disabled={claimIsPending}
/> />
)} </>
{!isMyCollection && ( ) : (
<Button <Button
title={__('Report content')} title={__('Report content')}
className="button--file-action" className="button--file-action"
icon={ICONS.REPORT} icon={ICONS.REPORT}
navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`} navigate={`/$/${PAGES.REPORT_CONTENT}?claimId=${claimId}`}
/> />
)} ))}
</> </>
); );

View file

@ -7,15 +7,18 @@ import {
makeSelectClaimForUri, makeSelectClaimForUri,
makeSelectClaimIsMine, makeSelectClaimIsMine,
} from 'lbry-redux'; } from 'lbry-redux';
import { import { selectPlayingUri, selectListLoop, selectListShuffle } from 'redux/selectors/content';
selectPlayingUri, import { doToggleLoopList, doToggleShuffleList } from 'redux/actions/content';
} from 'redux/selectors/content';
const select = (state, props) => { const select = (state, props) => {
const playingUri = selectPlayingUri(state); const playingUri = selectPlayingUri(state);
const playingUrl = playingUri && playingUri.uri; const playingUrl = playingUri && playingUri.uri;
const claim = makeSelectClaimForUri(playingUrl)(state); const claim = makeSelectClaimForUri(playingUrl)(state);
const url = claim && claim.permanent_url; 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 { return {
url, url,
@ -23,7 +26,12 @@ const select = (state, props) => {
collectionUrls: makeSelectUrlsForCollectionId(props.id)(state), collectionUrls: makeSelectUrlsForCollectionId(props.id)(state),
collectionName: makeSelectNameForCollectionId(props.id)(state), collectionName: makeSelectNameForCollectionId(props.id)(state),
isMine: makeSelectClaimIsMine(url)(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>, collectionUrls: Array<Claim>,
collectionName: string, collectionName: string,
collection: any, collection: any,
loop: boolean,
shuffle: boolean,
doToggleLoopList: (string, boolean) => void,
doToggleShuffleList: (string, string, boolean) => void,
createUnpublishedCollection: (string, Array<any>, ?string) => void, createUnpublishedCollection: (string, Array<any>, ?string) => void,
}; };
export default function CollectionContent(props: Props) { export default function CollectionContent(props: Props) {
const { collectionUrls, collectionName, id, url } = props; const { collectionUrls, collectionName, id, url, loop, shuffle, doToggleLoopList, doToggleShuffleList } = props;
return ( return (
<Card <Card
isBodyList isBodyList
className="file-page__recommended" className="file-page__recommended-collection"
title={ title={
<span> <>
<Icon <span className="file-page__recommended-collection__row">
icon={(id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) || <Icon
(id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) || ICONS.STACK} icon={
className="icon--margin-right" /> (id === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
{collectionName} (id === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
</span> 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={ titleActions={
<div className="card__title-actions--link"> <div className="card__title-actions--link">

View file

@ -1,11 +1,19 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doCollectionEdit, makeSelectNameForCollectionId, doCollectionDelete } from 'lbry-redux'; import { doCollectionEdit, makeSelectNameForCollectionId, doCollectionDelete } from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } from 'redux/actions/content';
import CollectionMenuList from './view'; import CollectionMenuList from './view';
const select = (state, props) => { 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 { return {
collectionName: makeSelectNameForCollectionId(props.collectionId)(state), collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
playNextUri,
}; };
}; };
@ -13,4 +21,6 @@ export default connect(select, {
doCollectionEdit, doCollectionEdit,
doOpenModal, doOpenModal,
doCollectionDelete, doCollectionDelete,
doSetPlayingUri,
doToggleShuffleList,
})(CollectionMenuList); })(CollectionMenuList);

View file

@ -7,19 +7,44 @@ import { Menu, MenuButton, MenuList, MenuItem } from '@reach/menu-button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url';
import { COLLECTIONS_CONSTS } from 'lbry-redux';
type Props = { type Props = {
inline?: boolean, inline?: boolean,
doOpenModal: (string, {}) => void, doOpenModal: (string, {}) => void,
collectionName?: string, collectionName?: string,
collectionId: string, collectionId: string,
playNextUri: string,
doSetPlayingUri: ({ uri: ?string }) => void,
doToggleShuffleList: (string, string, boolean, boolean) => void,
}; };
function CollectionMenuList(props: Props) { 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(); 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 ( return (
<Menu> <Menu>
<MenuButton <MenuButton
@ -35,9 +60,21 @@ function CollectionMenuList(props: Props) {
{collectionId && collectionName && ( {collectionId && collectionName && (
<> <>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}> <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} /> <Icon aria-hidden icon={ICONS.VIEW} />
{__('View List')} {__('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> </div>
</MenuItem> </MenuItem>
<MenuItem <MenuItem

View file

@ -2401,4 +2401,27 @@ export const icons = {
/> />
</svg> </svg>
), ),
[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

@ -22,6 +22,7 @@ const select = (state, props) => {
const playingUri = selectPlayingUri(state); const playingUri = selectPlayingUri(state);
const primaryUri = selectPrimaryUri(state); const primaryUri = selectPrimaryUri(state);
const uri = playingUri && playingUri.uri; const uri = playingUri && playingUri.uri;
const collectionId = playingUri && playingUri.collectionId;
return { return {
uri, uri,
@ -35,6 +36,7 @@ const select = (state, props) => {
floatingPlayerEnabled: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state), floatingPlayerEnabled: makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state),
renderMode: makeSelectFileRenderModeForUri(uri)(state), renderMode: makeSelectFileRenderModeForUri(uri)(state),
videoTheaterMode: makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)(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 { useIsMobile } from 'effects/use-screensize';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import { useHistory } from 'react-router'; 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 IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false;
const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 60; const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 60;
@ -34,6 +34,7 @@ type Props = {
primaryUri: ?string, primaryUri: ?string,
videoTheaterMode: boolean, videoTheaterMode: boolean,
doFetchRecommendedContent: (string, boolean) => void, doFetchRecommendedContent: (string, boolean) => void,
collectionId: string,
}; };
export default function FileRenderFloating(props: Props) { export default function FileRenderFloating(props: Props) {
@ -51,6 +52,7 @@ export default function FileRenderFloating(props: Props) {
primaryUri, primaryUri,
videoTheaterMode, videoTheaterMode,
doFetchRecommendedContent, doFetchRecommendedContent,
collectionId,
} = props; } = props;
const { const {
location: { pathname }, location: { pathname },
@ -69,6 +71,13 @@ export default function FileRenderFloating(props: Props) {
y: 0, 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 playingUriSource = playingUri && playingUri.source;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode); const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
const isReadyToPlay = isPlayable && (streamingUrl || (fileInfo && fileInfo.completed)); const isReadyToPlay = isPlayable && (streamingUrl || (fileInfo && fileInfo.completed));
@ -303,7 +312,12 @@ export default function FileRenderFloating(props: Props) {
{isFloating && ( {isFloating && (
<div className="draggable content__info"> <div className="draggable content__info">
<div className="claim-preview__title" title={title || uri}> <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> </div>
<UriIndicator link uri={uri} /> <UriIndicator link uri={uri} />
</div> </div>

View file

@ -7,6 +7,7 @@ import {
makeSelectStreamingUrlForUri, makeSelectStreamingUrlForUri,
makeSelectClaimWasPurchased, makeSelectClaimWasPurchased,
SETTINGS, SETTINGS,
COLLECTIONS_CONSTS,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectCostInfoForUri } from 'lbryinc'; import { makeSelectCostInfoForUri } from 'lbryinc';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -22,27 +23,34 @@ import {
import FileRenderInitiator from './view'; import FileRenderInitiator from './view';
import { doAnaltyicsPurchaseEvent } from 'redux/actions/app'; import { doAnaltyicsPurchaseEvent } from 'redux/actions/app';
const select = (state, props) => ({ const select = (state, props) => {
claimThumbnail: makeSelectThumbnailForUri(props.uri)(state), const { search } = props.location;
fileInfo: makeSelectFileInfoForUri(props.uri)(state), const urlParams = new URLSearchParams(search);
obscurePreview: makeSelectShouldObscurePreview(props.uri)(state), const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID);
isPlaying: makeSelectIsPlaying(props.uri)(state),
playingUri: selectPlayingUri(state), return {
insufficientCredits: makeSelectInsufficientCreditsForUri(props.uri)(state), claimThumbnail: makeSelectThumbnailForUri(props.uri)(state),
streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state), fileInfo: makeSelectFileInfoForUri(props.uri)(state),
autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state), obscurePreview: makeSelectShouldObscurePreview(props.uri)(state),
hasCostInfo: Boolean(makeSelectCostInfoForUri(props.uri)(state)), isPlaying: makeSelectIsPlaying(props.uri)(state),
costInfo: makeSelectCostInfoForUri(props.uri)(state), playingUri: selectPlayingUri(state),
renderMode: makeSelectFileRenderModeForUri(props.uri)(state), insufficientCredits: makeSelectInsufficientCreditsForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state), streamingUrl: makeSelectStreamingUrlForUri(props.uri)(state),
claimWasPurchased: makeSelectClaimWasPurchased(props.uri)(state), autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state),
authenticated: selectUserVerifiedEmail(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) => ({ const perform = (dispatch) => ({
play: (uri) => { play: (uri, collectionId) => {
dispatch(doSetPrimaryUri(uri)); dispatch(doSetPrimaryUri(uri));
dispatch(doSetPlayingUri({ uri })); dispatch(doSetPlayingUri({ uri, collectionId }));
dispatch(doPlayUri(uri, undefined, undefined, (fileInfo) => dispatch(doAnaltyicsPurchaseEvent(fileInfo)))); 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; const SPACE_BAR_KEYCODE = 32;
type Props = { type Props = {
play: (string) => void, play: (string, string) => void,
isLoading: boolean, isLoading: boolean,
isPlaying: boolean, isPlaying: boolean,
fileInfo: FileListItem, fileInfo: FileListItem,
@ -36,6 +36,7 @@ type Props = {
claimWasPurchased: boolean, claimWasPurchased: boolean,
authenticated: boolean, authenticated: boolean,
videoTheaterMode: boolean, videoTheaterMode: boolean,
collectionId: string,
}; };
export default function FileRenderInitiator(props: Props) { export default function FileRenderInitiator(props: Props) {
@ -55,6 +56,7 @@ export default function FileRenderInitiator(props: Props) {
claimWasPurchased, claimWasPurchased,
authenticated, authenticated,
videoTheaterMode, videoTheaterMode,
collectionId,
} = props; } = props;
// force autoplay if a timestamp is present // force autoplay if a timestamp is present
@ -109,9 +111,9 @@ export default function FileRenderInitiator(props: Props) {
e.stopPropagation(); e.stopPropagation();
} }
play(uri); play(uri, collectionId);
}, },
[play, uri] [play, uri, collectionId]
); );
useEffect(() => { useEffect(() => {

View file

@ -7,7 +7,7 @@ $transition-duration: 300ms;
// Classnames must line up with classes in classes.js // Classnames must line up with classes in classes.js
.ff-container { .ff-container {
display: inline-block; display: inline-block;
position: relative; position: absolute;
.ff-image { .ff-image {
z-index: $base-zindex; z-index: $base-zindex;

View file

@ -1,14 +1,22 @@
import { connect } from 'react-redux'; 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 { doChangeVolume, doChangeMute, doAnalyticsView, doAnalyticsBuffer } from 'redux/actions/app';
import { selectVolume, selectMute } from 'redux/selectors/app'; import { selectVolume, selectMute } from 'redux/selectors/app';
import { savePosition, clearPosition } from 'redux/actions/content'; import { savePosition, clearPosition, doSetPlayingUri, doPlayUri } from 'redux/actions/content';
import { makeSelectContentPositionForUri } from 'redux/selectors/content'; import { makeSelectContentPositionForUri, selectPlayingUri, makeSelectIsPlayerFloating } from 'redux/selectors/content';
import VideoViewer from './view'; import VideoViewer from './view';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards'; import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards';
import { selectDaemonSettings, makeSelectClientSetting, selectHomepageData } from 'redux/selectors/settings'; 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'; import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user';
const select = (state, props) => { const select = (state, props) => {
@ -18,6 +26,15 @@ const select = (state, props) => {
// TODO: eventually this should be received from DB and not local state (https://github.com/lbryio/lbry-desktop/issues/6796) // 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(props.uri)(state);
const userId = selectUser(state) && selectUser(state).id; const userId = selectUser(state) && selectUser(state).id;
const playingUri = selectPlayingUri(state);
const collectionId = urlParams.get(COLLECTIONS_CONSTS.COLLECTION_ID) || (playingUri && playingUri.collectionId);
let playNextUri;
let playPreviousUri;
if (collectionId) {
playNextUri = makeSelectNextUrlForCollectionAndUrl(collectionId, props.uri)(state);
playPreviousUri = makeSelectPreviousUrlForCollectionAndUrl(collectionId, props.uri)(state);
}
return { return {
autoplayIfEmbedded: Boolean(autoplay), autoplayIfEmbedded: Boolean(autoplay),
@ -33,6 +50,11 @@ const select = (state, props) => {
authenticated: selectUserVerifiedEmail(state), authenticated: selectUserVerifiedEmail(state),
userId: userId, userId: userId,
shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data, shareTelemetry: IS_WEB || selectDaemonSettings(state).share_usage_data,
videoTheaterMode: makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)(state),
isFloating: makeSelectIsPlayerFloating(props.location)(state),
collectionId,
playNextUri,
playPreviousUri,
}; };
}; };
@ -45,7 +67,10 @@ const perform = (dispatch) => ({
doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)), doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)),
claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()), claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()),
toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()), toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()),
toggleAutoplayNext: () => dispatch(toggleAutoplayNext()),
setVideoPlaybackRate: (rate) => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)), setVideoPlaybackRate: (rate) => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)),
doSetPlayingUri: (uri, collectionId) => dispatch(doSetPlayingUri({ uri, collectionId })),
doPlayUri: (uri) => dispatch(doPlayUri(uri)),
}); });
export default withRouter(connect(select, perform)(VideoViewer)); export default withRouter(connect(select, perform)(VideoViewer));

View file

@ -0,0 +1,29 @@
// @flow
import type { Player } from './videojs';
import videojs from 'video.js';
class AutoplayNextButton extends videojs.getComponent('Button') {
constructor(player, options = {}, autoplay) {
super(player, options, autoplay);
this.addClass(autoplay ? 'vjs-button--autoplay-next--active' : 'vjs-button--autoplay-next');
this.controlText(autoplay ? 'Autoplay Next On' : 'Autoplay Next Off');
}
}
export function addAutoplayNextButton(player: Player, toggleAutoplayNext: () => void, autoplay: boolean) {
const controlBar = player.getChild('controlBar');
const autoplayButton = new AutoplayNextButton(
player,
{
name: 'AutoplayNextButton',
text: __('Autoplay Next'),
clickHandler: () => {
toggleAutoplayNext();
},
},
autoplay
);
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 // @flow
import type { Player } from './videojs'; 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) { export function addTheaterModeButton(player: Player, toggleVideoTheaterMode: () => void) {
var myButton = player.controlBar.addChild('button', { const controlBar = player.getChild('controlBar');
const theaterMode = new TheaterModeButton(player, {
name: 'TheaterModeButton',
text: __('Theater mode'), text: __('Theater mode'),
clickHandler: () => { clickHandler: () => {
toggleVideoTheaterMode(); toggleVideoTheaterMode();
}, },
}); });
// $FlowFixMe controlBar.addChild(theaterMode);
myButton.addClass('vjs-button--theater-mode');
// $FlowFixMe
myButton.setAttribute('title', __('Theater mode'));
} }

View file

@ -49,16 +49,21 @@ type Props = {
source: string, source: string,
sourceType: string, sourceType: string,
poster: ?string, poster: ?string,
onPlayerReady: (Player) => void, onPlayerReady: (Player, any) => void,
isAudio: boolean, isAudio: boolean,
startMuted: boolean, startMuted: boolean,
autoplay: boolean, autoplay: boolean,
autoplaySetting: boolean,
toggleVideoTheaterMode: () => void, toggleVideoTheaterMode: () => void,
adUrl: ?string, adUrl: ?string,
claimId: ?string, claimId: ?string,
userId: ?number, userId: ?number,
// allowPreRoll: ?boolean, // allowPreRoll: ?boolean,
shareTelemetry: boolean, shareTelemetry: boolean,
replay: boolean,
videoTheaterMode: boolean,
setStartPlayPrevious: (boolean) => void,
setStartPlayNext: (boolean) => void,
}; };
// type VideoJSOptions = { // type VideoJSOptions = {
@ -103,6 +108,20 @@ const SMALL_J_KEYCODE = 74;
const SMALL_K_KEYCODE = 75; const SMALL_K_KEYCODE = 75;
const SMALL_L_KEYCODE = 76; const SMALL_L_KEYCODE = 76;
const P_KEYCODE = 80;
const N_KEYCODE = 78;
const ZERO_KEYCODE = 48;
const ONE_KEYCODE = 49;
const TWO_KEYCODE = 50;
const THREE_KEYCODE = 51;
const FOUR_KEYCODE = 52;
const FIVE_KEYCODE = 53;
const SIX_KEYCODE = 54;
const SEVEN_KEYCODE = 55;
const EIGHT_KEYCODE = 56;
const NINE_KEYCODE = 57;
const FULLSCREEN_KEYCODE = SMALL_F_KEYCODE; const FULLSCREEN_KEYCODE = SMALL_F_KEYCODE;
const MUTE_KEYCODE = SMALL_M_KEYCODE; const MUTE_KEYCODE = SMALL_M_KEYCODE;
const THEATER_MODE_KEYCODE = SMALL_T_KEYCODE; const THEATER_MODE_KEYCODE = SMALL_T_KEYCODE;
@ -185,6 +204,7 @@ properties for this component should be kept to ONLY those that if changed shoul
export default React.memo<Props>(function VideoJs(props: Props) { export default React.memo<Props>(function VideoJs(props: Props) {
const { const {
autoplay, autoplay,
autoplaySetting,
startMuted, startMuted,
source, source,
sourceType, sourceType,
@ -197,6 +217,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
userId, userId,
// allowPreRoll, // allowPreRoll,
shareTelemetry, shareTelemetry,
replay,
videoTheaterMode,
setStartPlayPrevious,
setStartPlayNext,
} = props; } = props;
const [reload, setReload] = useState('initial'); const [reload, setReload] = useState('initial');
@ -323,13 +347,11 @@ export default React.memo<Props>(function VideoJs(props: Props) {
resolveCtrlText({ type: 'pause' }); resolveCtrlText({ type: 'pause' });
resolveCtrlText({ type: 'volumechange' }); resolveCtrlText({ type: 'volumechange' });
resolveCtrlText({ type: 'fullscreenchange' }); resolveCtrlText({ type: 'fullscreenchange' });
// (1) The 'Theater mode' button should probably be changed to a class controlBar
// so that we can use getChild() with a specific name. There might be .getChild('TheaterModeButton')
// clashes if we add a new button in the future. .controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)'));
// (2) We'll have to get 'makeSelectClientSetting(SETTINGS.VIDEO_THEATER_MODE)' controlBar.getChild('PlayNextButton').controlText(__('Play Next (SHIFT+N)'));
// as a prop here so we can say "Theater mode|Default mode" instead of controlBar.getChild('PlayPreviousButton').controlText(__('Play Previous (SHIFT+P)'));
// "Toggle Theater mode".
controlBar.getChild('Button').controlText(__('Toggle Theater mode (t)'));
break; break;
default: default:
if (isDev) throw Error('Unexpected: ' + e.type); if (isDev) throw Error('Unexpected: ' + e.type);
@ -387,6 +409,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (e.altKey || e.ctrlKey || e.metaKey || !e.shiftKey) return; if (e.altKey || e.ctrlKey || e.metaKey || !e.shiftKey) return;
if (e.keyCode === PERIOD_KEYCODE) changePlaybackSpeed(true); if (e.keyCode === PERIOD_KEYCODE) changePlaybackSpeed(true);
if (e.keyCode === COMMA_KEYCODE) changePlaybackSpeed(false); if (e.keyCode === COMMA_KEYCODE) changePlaybackSpeed(false);
if (e.keyCode === P_KEYCODE) setStartPlayPrevious(true);
if (e.keyCode === N_KEYCODE) setStartPlayNext(true);
} }
function handleSingleKeyActions(e: KeyboardEvent) { function handleSingleKeyActions(e: KeyboardEvent) {
@ -397,19 +421,37 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (e.keyCode === VOLUME_UP_KEYCODE) volumeUp(); if (e.keyCode === VOLUME_UP_KEYCODE) volumeUp();
if (e.keyCode === VOLUME_DOWN_KEYCODE) volumeDown(); if (e.keyCode === VOLUME_DOWN_KEYCODE) volumeDown();
if (e.keyCode === THEATER_MODE_KEYCODE) toggleTheaterMode(); if (e.keyCode === THEATER_MODE_KEYCODE) toggleTheaterMode();
if (e.keyCode === SEEK_FORWARD_KEYCODE) seekVideo(SEEK_STEP); if (e.keyCode === SEEK_FORWARD_KEYCODE) seekVideo(SEEK_STEP, false);
if (e.keyCode === SEEK_BACKWARD_KEYCODE) seekVideo(-SEEK_STEP); if (e.keyCode === SEEK_BACKWARD_KEYCODE) seekVideo(-SEEK_STEP, false);
if (e.keyCode === SEEK_FORWARD_KEYCODE_5) seekVideo(SEEK_STEP_5); if (e.keyCode === SEEK_FORWARD_KEYCODE_5) seekVideo(SEEK_STEP_5, false);
if (e.keyCode === SEEK_BACKWARD_KEYCODE_5) seekVideo(-SEEK_STEP_5); if (e.keyCode === SEEK_BACKWARD_KEYCODE_5) seekVideo(-SEEK_STEP_5, false);
if (e.keyCode === ZERO_KEYCODE) seekVideo(0, true);
if (e.keyCode === ONE_KEYCODE) seekVideo(1, true);
if (e.keyCode === TWO_KEYCODE) seekVideo(2, true);
if (e.keyCode === THREE_KEYCODE) seekVideo(3, true);
if (e.keyCode === FOUR_KEYCODE) seekVideo(4, true);
if (e.keyCode === FIVE_KEYCODE) seekVideo(5, true);
if (e.keyCode === SIX_KEYCODE) seekVideo(6, true);
if (e.keyCode === SEVEN_KEYCODE) seekVideo(7, true);
if (e.keyCode === EIGHT_KEYCODE) seekVideo(8, true);
if (e.keyCode === NINE_KEYCODE) seekVideo(9, true);
} }
function seekVideo(stepSize: number) { function seekVideo(stepSize: number, numberedStep: boolean) {
const player = playerRef.current; const player = playerRef.current;
const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); const videoNode = containerRef.current && containerRef.current.querySelector('video, audio');
if (!videoNode || !player) return; if (!videoNode || !player) return;
const duration = videoNode.duration; const duration = videoNode.duration;
const numberedStepSize = duration / 10;
const currentTime = videoNode.currentTime; const currentTime = videoNode.currentTime;
const newDuration = currentTime + stepSize;
let newDuration;
if (numberedStep) {
newDuration = numberedStepSize * stepSize;
} else {
newDuration = currentTime + stepSize;
}
if (newDuration < 0) { if (newDuration < 0) {
videoNode.currentTime = 0; videoNode.currentTime = 0;
} else if (newDuration > duration) { } else if (newDuration > duration) {
@ -417,7 +459,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
} else { } else {
videoNode.currentTime = newDuration; videoNode.currentTime = newDuration;
} }
OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0);
if (!numberedStep) {
OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0);
}
player.userActive(true); player.userActive(true);
} }
@ -580,7 +625,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
player.children_[0].setAttribute('playsinline', ''); player.children_[0].setAttribute('playsinline', '');
// I think this is a callback function // I think this is a callback function
onPlayerReady(player); const videoNode = containerRef.current && containerRef.current.querySelector('video, audio');
onPlayerReady(player, videoNode);
}); });
// pre-roll ads // pre-roll ads
@ -601,6 +647,41 @@ export default React.memo<Props>(function VideoJs(props: Props) {
return vjs; return vjs;
} }
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 controlBar = player.getChild('controlBar');
const autoplayButton = controlBar.getChild('AutoplayNextButton');
if (autoplaySetting) {
autoplayButton.removeClass('vjs-button--autoplay-next');
autoplayButton.addClass('vjs-button--autoplay-next--active');
autoplayButton.controlText('Autoplay Next On');
} else {
autoplayButton.removeClass('vjs-button--autoplay-next--active');
autoplayButton.addClass('vjs-button--autoplay-next');
autoplayButton.controlText('Autoplay Next Off');
}
}
}, [autoplaySetting]);
// This lifecycle hook is only called once (on mount), or when `isAudio` changes. // This lifecycle hook is only called once (on mount), or when `isAudio` changes.
useEffect(() => { useEffect(() => {
const vjsElement = createVideoPlayerDOM(containerRef.current); const vjsElement = createVideoPlayerDOM(containerRef.current);

View file

@ -16,12 +16,17 @@ import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded';
import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle'; import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle';
import LoadingScreen from 'component/common/loading-screen'; import LoadingScreen from 'component/common/loading-screen';
import { addTheaterModeButton } from './internal/theater-mode'; 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 { useGetAds } from 'effects/use-get-ads';
import Button from 'component/button'; import Button from 'component/button';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { getAllIds } from 'util/buildHomepage'; import { getAllIds } from 'util/buildHomepage';
import type { HomepageCat } 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_ERROR = 'play_timeout_error';
const PLAY_TIMEOUT_LIMIT = 2000; const PLAY_TIMEOUT_LIMIT = 2000;
@ -47,11 +52,19 @@ type Props = {
savePosition: (string, number) => void, savePosition: (string, number) => void,
clearPosition: (string) => void, clearPosition: (string) => void,
toggleVideoTheaterMode: () => void, toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void, setVideoPlaybackRate: (number) => void,
doSetPlayingUri: (string, string) => void,
doPlayUri: (string) => void,
playNextUri: string,
playPreviousUri: string,
authenticated: boolean, authenticated: boolean,
userId: number, userId: number,
homepageData?: { [string]: HomepageCat }, homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean, shareTelemetry: boolean,
videoTheaterMode: boolean,
collectionId: string,
isFloating: boolean,
}; };
/* /*
@ -81,11 +94,19 @@ function VideoViewer(props: Props) {
clearPosition, clearPosition,
desktopPlayStartTime, desktopPlayStartTime,
toggleVideoTheaterMode, toggleVideoTheaterMode,
toggleAutoplayNext,
setVideoPlaybackRate, setVideoPlaybackRate,
doSetPlayingUri,
doPlayUri,
playNextUri,
playPreviousUri,
homepageData, homepageData,
authenticated, authenticated,
userId, userId,
shareTelemetry, shareTelemetry,
videoTheaterMode,
collectionId,
isFloating,
} = props; } = props;
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : []; const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
@ -95,8 +116,10 @@ function VideoViewer(props: Props) {
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType); const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
const { const {
location: { pathname }, location: { pathname },
push,
} = useHistory(); } = useHistory();
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false);
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false); const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const [isEndededEmbed, setIsEndededEmbed] = useState(false); const [isEndededEmbed, setIsEndededEmbed] = useState(false);
const vjsCallbackDataRef: any = React.useRef(); const vjsCallbackDataRef: any = React.useRef();
@ -108,6 +131,26 @@ function VideoViewer(props: Props) {
/* isLoading was designed to show loading screen on first play press, rather than completely black screen, but /* 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 */ breaks because some browsers (e.g. Firefox) block autoplay but leave the player.play Promise pending */
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [replay, setReplay] = useState(false);
const [startPlayNext, setStartPlayNext] = useState(false);
const [startPlayPrevious, setStartPlayPrevious] = useState(false);
const [videoNode, setVideoNode] = useState(false);
const getNavigateUrl = React.useCallback(
(playUri: string) => {
let navigateUrl;
if (playUri) {
navigateUrl = formatLbryUrlForWeb(playUri);
if (collectionId) {
const collectionParams = new URLSearchParams();
collectionParams.set(COLLECTIONS_CONSTS.COLLECTION_ID, collectionId);
navigateUrl = navigateUrl + `?` + collectionParams.toString();
}
}
return navigateUrl;
},
[collectionId]
);
// force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true) // force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true)
useEffect(() => { useEffect(() => {
@ -126,6 +169,51 @@ function VideoViewer(props: Props) {
}; };
}, [embedded, videoPlaybackRate]); }, [embedded, videoPlaybackRate]);
useEffect(() => {
if (startPlayNext) {
const navigateUrl = getNavigateUrl(playNextUri);
if (!isFloating && navigateUrl) {
push(navigateUrl);
}
if (playNextUri) {
doSetPlayingUri(playNextUri, collectionId);
doPlayUri(playNextUri);
}
setStartPlayNext(false);
}
if (videoNode) {
const currentTime = videoNode.currentTime;
if (startPlayPrevious) {
if (currentTime > 5) {
videoNode.currentTime = 0;
} else {
const navigateUrl = getNavigateUrl(playPreviousUri);
if (!isFloating && navigateUrl) {
push(navigateUrl);
}
if (playPreviousUri) {
doSetPlayingUri(playPreviousUri, collectionId);
doPlayUri(playPreviousUri);
}
}
setStartPlayPrevious(false);
}
}
}, [
isFloating,
push,
doSetPlayingUri,
playNextUri,
doPlayUri,
startPlayNext,
collectionId,
getNavigateUrl,
videoNode,
startPlayPrevious,
playPreviousUri,
]);
function doTrackingBuffered(e: Event, data: any) { function doTrackingBuffered(e: Event, data: any) {
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => { fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
data.playerPoweredBy = response.headers.get('x-powered-by'); data.playerPoweredBy = response.headers.get('x-powered-by');
@ -152,28 +240,41 @@ function VideoViewer(props: Props) {
}); });
} }
const onEnded = React.useCallback(() => { React.useEffect(() => {
analytics.videoIsPlaying(false); if (ended) {
analytics.videoIsPlaying(false);
if (adUrl) { if (adUrl) {
setAdUrl(null); setAdUrl(null);
return; return;
}
if (embedded) {
setIsEndededEmbed(true);
} else if (autoplaySetting) {
setShowAutoplayCountdown(true);
}
clearPosition(uri);
} }
}, [
if (embedded) { embedded,
setIsEndededEmbed(true); setIsEndededEmbed,
} else if (autoplaySetting) { autoplaySetting,
setShowAutoplayCountdown(true); setShowAutoplayCountdown,
} adUrl,
setAdUrl,
clearPosition(uri); clearPosition,
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl, clearPosition, uri]); uri,
ended,
]);
function onPlay(player) { function onPlay(player) {
setIsLoading(false); setIsLoading(false);
setIsPlaying(true); setIsPlaying(true);
setShowAutoplayCountdown(false); setShowAutoplayCountdown(false);
setIsEndededEmbed(false); setIsEndededEmbed(false);
setReplay(false);
analytics.videoIsPlaying(true, player); analytics.videoIsPlaying(true, player);
} }
@ -203,12 +304,19 @@ function VideoViewer(props: Props) {
playerReadyDependencyList.push(desktopPlayStartTime); playerReadyDependencyList.push(desktopPlayStartTime);
} }
const onPlayerReady = useCallback((player: Player) => { const onPlayerReady = useCallback((player: Player, videoNode: any) => {
if (!embedded) { if (!embedded) {
setVideoNode(videoNode);
player.muted(muted); player.muted(muted);
player.volume(volume); player.volume(volume);
player.playbackRate(videoPlaybackRate); player.playbackRate(videoPlaybackRate);
addTheaterModeButton(player, toggleVideoTheaterMode); addTheaterModeButton(player, toggleVideoTheaterMode);
if (collectionId) {
addPlayNextButton(player, () => setStartPlayNext(true));
addPlayPreviousButton(player, () => setStartPlayPrevious(true));
} else {
addAutoplayNextButton(player, toggleAutoplayNext, autoplaySetting);
}
} }
const shouldPlay = !embedded || autoplayIfEmbedded; const shouldPlay = !embedded || autoplayIfEmbedded;
@ -244,7 +352,7 @@ function VideoViewer(props: Props) {
// first play tracking, used for initializing the watchman api // first play tracking, used for initializing the watchman api
player.on('tracking:firstplay', doTrackingFirstPlay); player.on('tracking:firstplay', doTrackingFirstPlay);
player.on('ended', onEnded); player.on('ended', () => setEnded(true));
player.on('play', onPlay); player.on('play', onPlay);
player.on('pause', (event) => onPause(event, player)); player.on('pause', (event) => onPause(event, player));
player.on('dispose', (event) => onDispose(event, player)); player.on('dispose', (event) => onDispose(event, player));
@ -285,7 +393,7 @@ function VideoViewer(props: Props) {
})} })}
onContextMenu={stopContextMenu} onContextMenu={stopContextMenu}
> >
{showAutoplayCountdown && <AutoplayCountdown uri={uri} />} {showAutoplayCountdown && <AutoplayCountdown uri={uri} setReplay={setReplay} />}
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />} {isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />} {embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{/* disable this loading behavior because it breaks when player.play() promise hangs */} {/* disable this loading behavior because it breaks when player.play() promise hangs */}
@ -332,10 +440,15 @@ function VideoViewer(props: Props) {
startMuted={autoplayIfEmbedded} startMuted={autoplayIfEmbedded}
toggleVideoTheaterMode={toggleVideoTheaterMode} toggleVideoTheaterMode={toggleVideoTheaterMode}
autoplay={!embedded || autoplayIfEmbedded} autoplay={!embedded || autoplayIfEmbedded}
autoplaySetting={autoplaySetting}
claimId={claimId} claimId={claimId}
userId={userId} userId={userId}
allowPreRoll={!embedded && !authenticated} allowPreRoll={!embedded && !authenticated}
shareTelemetry={shareTelemetry} shareTelemetry={shareTelemetry}
replay={replay}
videoTheaterMode={videoTheaterMode}
setStartPlayNext={setStartPlayNext}
setStartPlayPrevious={setStartPlayPrevious}
/> />
)} )}
</div> </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 CLEAR_CONTENT_HISTORY_ALL = 'CLEAR_CONTENT_HISTORY_ALL';
export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED'; export const RECOMMENDATION_UPDATED = 'RECOMMENDATION_UPDATED';
export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED'; export const RECOMMENDATION_CLICKED = 'RECOMMENDATION_CLICKED';
export const TOGGLE_LOOP_LIST = 'TOGGLE_LOOP_LIST';
export const TOGGLE_SHUFFLE_LIST = 'TOGGLE_SHUFFLE_LIST';
// Files // Files
export const FILE_LIST_STARTED = 'FILE_LIST_STARTED'; export const FILE_LIST_STARTED = 'FILE_LIST_STARTED';

View file

@ -169,3 +169,6 @@ export const CONTENT = 'Content';
export const STAR = 'star'; export const STAR = 'star';
export const MUSIC = 'MusicCategory'; export const MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod'; export const BADGE_MOD = 'BadgeMod';
export const REPLAY = 'Replay';
export const REPEAT = 'Repeat';
export const SHUFFLE = 'Shuffle';

View file

@ -36,7 +36,7 @@ function ModalRemoveCollection(props: Props) {
</React.Fragment> </React.Fragment>
) : ( ) : (
<I18nMessage tokens={{ title: <cite>{uri && title ? `"${title}"` : `"${collectionName}"`}</cite> }}> <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> </I18nMessage>
) )
} }

View file

@ -112,18 +112,26 @@ export default function CollectionPage(props: Props) {
title={ title={
<span> <span>
<Icon <Icon
icon={(collectionId === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) || icon={
(collectionId === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) || ICONS.STACK} (collectionId === COLLECTIONS_CONSTS.WATCH_LATER_ID && ICONS.TIME) ||
className="icon--margin-right" /> (collectionId === COLLECTIONS_CONSTS.FAVORITES_ID && ICONS.STAR) ||
ICONS.STACK
}
className="icon--margin-right"
/>
{claim ? claim.value.title || claim.name : collection && collection.name} {claim ? claim.value.title || claim.name : collection && collection.name}
</span> </span>
} }
titleActions={titleActions} titleActions={titleActions}
subtitle={subTitle} subtitle={subTitle}
body={ body={
!isBuiltin && ( <CollectionActions
<CollectionActions uri={uri} collectionId={collectionId} setShowInfo={setShowInfo} showInfo={showInfo} /> uri={uri}
) collectionId={collectionId}
setShowInfo={setShowInfo}
showInfo={showInfo}
isBuiltin={isBuiltin}
/>
} }
actions={ actions={
showInfo && showInfo &&

View file

@ -16,6 +16,8 @@ import {
makeSelectClaimForUri, makeSelectClaimForUri,
makeSelectClaimIsMine, makeSelectClaimIsMine,
makeSelectClaimWasPurchased, makeSelectClaimWasPurchased,
doToast,
makeSelectUrlsForCollectionId,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc'; import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc';
import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings'; import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
@ -110,16 +112,18 @@ export function doSetPlayingUri({
source, source,
pathname, pathname,
commentId, commentId,
collectionId,
}: { }: {
uri: ?string, uri: ?string,
source?: string, source?: string,
commentId?: string, commentId?: string,
pathname: string, pathname: string,
collectionId: string,
}) { }) {
return (dispatch: Dispatch) => { return (dispatch: Dispatch) => {
dispatch({ dispatch({
type: ACTIONS.SET_PLAYING_URI, 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; const UPDATE_IS_NIGHT_INTERVAL = 5 * 60 * 1000;
export function doFetchDaemonSettings() { export function doFetchDaemonSettings() {
return dispatch => { return (dispatch) => {
Lbry.settings_get().then(settings => { Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data); analytics.toggleInternal(settings.share_usage_data);
dispatch({ dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED, type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -32,11 +32,11 @@ export function doFetchDaemonSettings() {
} }
export function doFindFFmpeg() { export function doFindFFmpeg() {
return dispatch => { return (dispatch) => {
dispatch({ dispatch({
type: LOCAL_ACTIONS.FINDING_FFMPEG_STARTED, type: LOCAL_ACTIONS.FINDING_FFMPEG_STARTED,
}); });
return Lbry.ffmpeg_find().then(done => { return Lbry.ffmpeg_find().then((done) => {
dispatch(doGetDaemonStatus()); dispatch(doGetDaemonStatus());
dispatch({ dispatch({
type: LOCAL_ACTIONS.FINDING_FFMPEG_COMPLETED, type: LOCAL_ACTIONS.FINDING_FFMPEG_COMPLETED,
@ -46,8 +46,8 @@ export function doFindFFmpeg() {
} }
export function doGetDaemonStatus() { export function doGetDaemonStatus() {
return dispatch => { return (dispatch) => {
return Lbry.status().then(status => { return Lbry.status().then((status) => {
dispatch({ dispatch({
type: ACTIONS.DAEMON_STATUS_RECEIVED, type: ACTIONS.DAEMON_STATUS_RECEIVED,
data: { data: {
@ -72,7 +72,7 @@ export function doClearDaemonSetting(key) {
key, key,
}; };
// not if syncLocked // not if syncLocked
Lbry.settings_clear(clearKey).then(defaultSettings => { Lbry.settings_clear(clearKey).then((defaultSettings) => {
if (SDK_SYNC_KEYS.includes(key)) { if (SDK_SYNC_KEYS.includes(key)) {
dispatch({ dispatch({
type: ACTIONS.SHARED_PREFERENCE_SET, type: ACTIONS.SHARED_PREFERENCE_SET,
@ -83,7 +83,7 @@ export function doClearDaemonSetting(key) {
dispatch(doWalletReconnect()); dispatch(doWalletReconnect());
} }
}); });
Lbry.settings_get().then(settings => { Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data); analytics.toggleInternal(settings.share_usage_data);
dispatch({ dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED, type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -108,7 +108,7 @@ export function doSetDaemonSetting(key, value, doNotDispatch = false) {
key, key,
value: !value && value !== false ? null : value, 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) { if (SDK_SYNC_KEYS.includes(key) && !doNotDispatch) {
dispatch({ dispatch({
type: ACTIONS.SHARED_PREFERENCE_SET, type: ACTIONS.SHARED_PREFERENCE_SET,
@ -121,7 +121,7 @@ export function doSetDaemonSetting(key, value, doNotDispatch = false) {
// todo: add sdk reloadsettings() (or it happens automagically?) // todo: add sdk reloadsettings() (or it happens automagically?)
} }
}); });
Lbry.settings_get().then(settings => { Lbry.settings_get().then((settings) => {
analytics.toggleInternal(settings.share_usage_data); analytics.toggleInternal(settings.share_usage_data);
dispatch({ dispatch({
type: ACTIONS.DAEMON_SETTINGS_RECEIVED, type: ACTIONS.DAEMON_SETTINGS_RECEIVED,
@ -170,7 +170,7 @@ export function doUpdateIsNight() {
} }
export function doUpdateIsNightAsync() { export function doUpdateIsNightAsync() {
return dispatch => { return (dispatch) => {
dispatch(doUpdateIsNight()); dispatch(doUpdateIsNight());
setInterval(() => dispatch(doUpdateIsNight()), UPDATE_IS_NIGHT_INTERVAL); setInterval(() => dispatch(doUpdateIsNight()), UPDATE_IS_NIGHT_INTERVAL);
@ -201,8 +201,8 @@ export function doSetDarkTime(value, options) {
export function doGetWalletSyncPreference() { export function doGetWalletSyncPreference() {
const SYNC_KEY = 'enable-sync'; const SYNC_KEY = 'enable-sync';
return dispatch => { return (dispatch) => {
return Lbry.preference_get({ key: SYNC_KEY }).then(result => { return Lbry.preference_get({ key: SYNC_KEY }).then((result) => {
const enabled = result && result[SYNC_KEY]; const enabled = result && result[SYNC_KEY];
if (enabled !== null) { if (enabled !== null) {
dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled)); dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled));
@ -214,8 +214,8 @@ export function doGetWalletSyncPreference() {
export function doSetWalletSyncPreference(pref) { export function doSetWalletSyncPreference(pref) {
const SYNC_KEY = 'enable-sync'; const SYNC_KEY = 'enable-sync';
return dispatch => { return (dispatch) => {
return Lbry.preference_set({ key: SYNC_KEY, value: pref }).then(result => { return Lbry.preference_set({ key: SYNC_KEY, value: pref }).then((result) => {
const enabled = result && result[SYNC_KEY]; const enabled = result && result[SYNC_KEY];
if (enabled !== null) { if (enabled !== null) {
dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled)); dispatch(doSetClientSetting(SETTINGS.ENABLE_SYNC, enabled));
@ -226,7 +226,7 @@ export function doSetWalletSyncPreference(pref) {
} }
export function doPushSettingsToPrefs() { export function doPushSettingsToPrefs() {
return dispatch => { return (dispatch) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
dispatch({ dispatch({
type: LOCAL_ACTIONS.SYNC_CLIENT_SETTINGS, type: LOCAL_ACTIONS.SYNC_CLIENT_SETTINGS,
@ -274,8 +274,8 @@ export function doFetchLanguage(language) {
if (settings.language !== language || (settings.loadedLanguages && !settings.loadedLanguages.includes(language))) { if (settings.language !== language || (settings.loadedLanguages && !settings.loadedLanguages.includes(language))) {
// this should match the behavior/logic in index-web.html // this should match the behavior/logic in index-web.html
fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json') fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json')
.then(r => r.json()) .then((r) => r.json())
.then(j => { .then((j) => {
window.i18n_messages[language] = j; window.i18n_messages[language] = j;
dispatch({ dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_SUCCESS, type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_SUCCESS,
@ -284,7 +284,7 @@ export function doFetchLanguage(language) {
}, },
}); });
}) })
.catch(e => { .catch((e) => {
dispatch({ dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_FAILURE, type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_FAILURE,
}); });
@ -324,8 +324,8 @@ export function doSetLanguage(language) {
) { ) {
// this should match the behavior/logic in index-web.html // this should match the behavior/logic in index-web.html
fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json') fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + language + '.json')
.then(r => r.json()) .then((r) => r.json())
.then(j => { .then((j) => {
window.i18n_messages[language] = j; window.i18n_messages[language] = j;
dispatch({ dispatch({
type: LOCAL_ACTIONS.DOWNLOAD_LANGUAGE_SUCCESS, 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); window.localStorage.setItem(SETTINGS.LANGUAGE, DEFAULT_LANGUAGE);
dispatch(doSetClientSetting(SETTINGS.LANGUAGE, DEFAULT_LANGUAGE)); dispatch(doSetClientSetting(SETTINGS.LANGUAGE, DEFAULT_LANGUAGE));
const languageName = SUPPORTED_LANGUAGES[language] ? SUPPORTED_LANGUAGES[language] : language; const languageName = SUPPORTED_LANGUAGES[language] ? SUPPORTED_LANGUAGES[language] : language;
@ -369,7 +369,7 @@ export function doSetAutoLaunch(value) {
} }
if (value === undefined) { if (value === undefined) {
launcher.isEnabled().then(isEnabled => { launcher.isEnabled().then((isEnabled) => {
if (isEnabled) { if (isEnabled) {
if (!autoLaunch) { if (!autoLaunch) {
launcher.disable().then(() => { launcher.disable().then(() => {
@ -385,7 +385,7 @@ export function doSetAutoLaunch(value) {
} }
}); });
} else if (value === true) { } else if (value === true) {
launcher.isEnabled().then(function(isEnabled) { launcher.isEnabled().then(function (isEnabled) {
if (!isEnabled) { if (!isEnabled) {
launcher.enable().then(() => { launcher.enable().then(() => {
dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, true)); dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, true));
@ -396,7 +396,7 @@ export function doSetAutoLaunch(value) {
}); });
} else { } else {
// value = false // value = false
launcher.isEnabled().then(function(isEnabled) { launcher.isEnabled().then(function (isEnabled) {
if (isEnabled) { if (isEnabled) {
launcher.disable().then(() => { launcher.disable().then(() => {
dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, false)); dispatch(doSetClientSetting(SETTINGS.AUTO_LAUNCH, false));
@ -410,7 +410,7 @@ export function doSetAutoLaunch(value) {
} }
export function doSetAppToTrayWhenClosed(value) { export function doSetAppToTrayWhenClosed(value) {
return dispatch => { return (dispatch) => {
window.localStorage.setItem(SETTINGS.TO_TRAY_WHEN_CLOSED, value); window.localStorage.setItem(SETTINGS.TO_TRAY_WHEN_CLOSED, value);
dispatch(doSetClientSetting(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)); dispatch(doSetClientSetting(SETTINGS.VIDEO_THEATER_MODE, !videoTheaterMode));
}; };
} }
export function toggleAutoplayNext() {
return (dispatch, getState) => {
const state = getState();
const autoplayNext = makeSelectClientSetting(SETTINGS.AUTOPLAY)(state);
dispatch(doSetClientSetting(SETTINGS.AUTOPLAY, !autoplayNext));
};
}

View file

@ -25,10 +25,27 @@ reducers[ACTIONS.SET_PLAYING_URI] = (state, action) =>
source: action.data.source, source: action.data.source,
pathname: action.data.pathname, pathname: action.data.pathname,
commentId: action.data.commentId, commentId: action.data.commentId,
collectionId: action.data.collectionId,
primaryUri: state.primaryUri, 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) => { reducers[ACTIONS.SET_CONTENT_POSITION] = (state, action) => {
const { claimId, outpoint, position } = action.data; const { claimId, outpoint, position } = action.data;
return { return {

View file

@ -29,6 +29,9 @@ export const selectState = (state: any) => state.content || {};
export const selectPlayingUri = createSelector(selectState, (state) => state.playingUri); export const selectPlayingUri = createSelector(selectState, (state) => state.playingUri);
export const selectPrimaryUri = createSelector(selectState, (state) => state.primaryUri); 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) => export const makeSelectIsPlaying = (uri: string) =>
createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri); createSelector(selectPrimaryUri, (primaryUri) => primaryUri === uri);

View file

@ -132,6 +132,78 @@
} }
} }
.vjs-button--autoplay-next.vjs-button {
@media (min-width: $breakpoint-medium) {
display: block;
order: 1;
background-repeat: no-repeat;
background-position: center;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-toggle-left'%3e%3crect x='1' y='5' width='22' height='14' rx='7' ry='7'%3e%3c/rect%3e%3ccircle cx='8' cy='12' r='3'%3e%3c/circle%3e%3c/svg%3e");
}
&:focus:not(:focus-visible) {
// Need to repeat these styles because of video.js weirdness
// see: https://github.com/lbryio/lbry-desktop/pull/5549#discussion_r580406932
background-repeat: no-repeat;
background-position: center;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-toggle-left'%3e%3crect x='1' y='5' width='22' height='14' rx='7' ry='7'%3e%3c/rect%3e%3ccircle cx='8' cy='12' r='3'%3e%3c/circle%3e%3c/svg%3e");
}
}
.vjs-button--autoplay-next--active.vjs-button {
@media (min-width: $breakpoint-medium) {
display: block;
order: 1;
background-repeat: no-repeat;
background-position: center;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-toggle-right'%3e%3crect x='1' y='5' width='22' height='14' rx='7' ry='7'%3e%3c/rect%3e%3ccircle cx='16' cy='12' r='3'%3e%3c/circle%3e%3c/svg%3e");
}
&:focus:not(:focus-visible) {
// Need to repeat these styles because of video.js weirdness
// see: https://github.com/lbryio/lbry-desktop/pull/5549#discussion_r580406932
background-repeat: no-repeat;
background-position: center;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-toggle-right'%3e%3crect x='1' y='5' width='22' height='14' rx='7' ry='7'%3e%3c/rect%3e%3ccircle cx='16' cy='12' r='3'%3e%3c/circle%3e%3c/svg%3e");
}
}
.vjs-button--play-previous.vjs-button {
@media (min-width: $breakpoint-medium) {
display: block;
order: 0;
background-repeat: no-repeat;
background-position: center;
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) {
display: block;
order: 0;
background-repeat: no-repeat;
background-position: center;
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--play-next.vjs-button {
@media (min-width: $breakpoint-medium) {
display: block;
order: 0;
background-repeat: no-repeat;
background-position: center;
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) {
display: block;
order: 0;
background-repeat: no-repeat;
background-position: center;
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");
}
}
.button--link { .button--link {
color: var(--color-link); color: var(--color-link);
transition: color 0.2s; transition: color 0.2s;

View file

@ -314,6 +314,7 @@ $thumbnailWidthSmall: 1rem;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
.menu__link { .menu__link {
color: var(--color-text);
padding: 0; 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) { @media (max-width: $breakpoint-medium) {
flex-direction: column; flex-direction: column;
> :first-child { > :first-child {

View file

@ -10129,9 +10129,9 @@ lazy-val@^1.0.4:
yargs "^13.2.2" yargs "^13.2.2"
zstd-codec "^0.1.1" zstd-codec "^0.1.1"
lbry-redux@lbryio/lbry-redux#e4d0662100a5f4b28bb1bf3cbc1e51b2eebab5b6: lbry-redux@lbryio/lbry-redux#aeb1f533b590c0a9a63954beaa52e92d6f0668e3:
version "0.0.1" version "0.0.1"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/e4d0662100a5f4b28bb1bf3cbc1e51b2eebab5b6" resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/aeb1f533b590c0a9a63954beaa52e92d6f0668e3"
dependencies: dependencies:
proxy-polyfill "0.1.6" proxy-polyfill "0.1.6"
reselect "^3.0.0" reselect "^3.0.0"