Compare commits

...

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",
"json-loader": "^0.5.4",
"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",
"lint-staged": "^7.0.2",
"localforage": "^1.7.1",

View file

@ -1,7 +1,11 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri, SETTINGS, COLLECTIONS_CONSTS, makeSelectNextUrlForCollectionAndUrl } from 'lbry-redux';
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 { doSetPlayingUri, doPlayUri } from 'redux/actions/content';
import AutoplayCountdown from './view';
@ -15,7 +19,8 @@ const select = (state, props) => {
const { location } = props;
const { search } = location;
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;
if (collectionId) {

View file

@ -7,6 +7,8 @@ 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';
@ -15,10 +17,11 @@ type Props = {
nextRecommendedClaim: ?StreamClaim,
nextRecommendedUri: string,
isFloating: boolean,
doSetPlayingUri: ({ uri: ?string }) => void,
doSetPlayingUri: ({ uri: ?string, collectionId: ?string }) => void,
doPlayUri: (string) => void,
modal: { id: string, modalProps: {} },
collectionId?: string,
setReplay: (boolean) => void,
};
function AutoplayCountdown(props: Props) {
@ -31,6 +34,7 @@ function AutoplayCountdown(props: Props) {
history: { push },
modal,
collectionId,
setReplay,
} = props;
const nextTitle = nextRecommendedClaim && nextRecommendedClaim.value && nextRecommendedClaim.value.title;
@ -54,19 +58,14 @@ function AutoplayCountdown(props: Props) {
}
const doNavigate = useCallback(() => {
if (!isFloating) {
if (navigateUrl) {
push(navigateUrl);
doSetPlayingUri({ uri: nextRecommendedUri });
doPlayUri(nextRecommendedUri);
}
} else {
if (nextRecommendedUri) {
doSetPlayingUri({ uri: nextRecommendedUri });
doPlayUri(nextRecommendedUri);
}
if (!isFloating && navigateUrl) {
push(navigateUrl);
}
}, [navigateUrl, nextRecommendedUri, isFloating, doSetPlayingUri, doPlayUri, push]);
if (nextRecommendedUri) {
doSetPlayingUri({ uri: nextRecommendedUri, collectionId });
doPlayUri(nextRecommendedUri);
}
}, [isFloating, navigateUrl, nextRecommendedUri, push, doSetPlayingUri, collectionId, doPlayUri]);
function shouldPauseAutoplay() {
const elm = document.querySelector(`.${CLASSNAME_AUTOPLAY_COUNTDOWN}`);
@ -88,26 +87,30 @@ function AutoplayCountdown(props: Props) {
// Update countdown timer.
React.useEffect(() => {
let interval;
if (!timerCanceled && nextRecommendedUri) {
if (isTimerPaused) {
clearInterval(interval);
setTimer(countdownTime);
} else {
interval = setInterval(() => {
const newTime = timer - 1;
if (newTime === 0) {
doNavigate();
} else {
setTimer(timer - 1);
}
}, 1000);
if (collectionId) {
doNavigate();
} else {
let interval;
if (!timerCanceled && nextRecommendedUri) {
if (isTimerPaused) {
clearInterval(interval);
setTimer(countdownTime);
} else {
interval = setInterval(() => {
const newTime = timer - 1;
if (newTime === 0) {
doNavigate();
} else {
setTimer(timer - 1);
}
}, 1000);
}
}
return () => {
clearInterval(interval);
};
}
return () => {
clearInterval(interval);
};
}, [timer, doNavigate, navigateUrl, push, timerCanceled, isTimerPaused, nextRecommendedUri]);
}, [timer, doNavigate, navigateUrl, push, timerCanceled, isTimerPaused, nextRecommendedUri, collectionId]);
if (timerCanceled || !nextRecommendedUri) {
return null;
@ -138,6 +141,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);
setReplay(true);
}}
/>
</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,
fetchCollectionItems: (string) => void,
resolvedList: boolean,
playNextUri: string,
doSetPlayingUri: (string) => void,
doToggleShuffleList: (string) => void,
};
function ClaimMenuList(props: Props) {
@ -93,7 +98,13 @@ function ClaimMenuList(props: Props) {
isChannelPage = false,
editedCollection,
isAuthenticated,
fetchCollectionItems,
resolvedList,
playNextUri,
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 (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) {
return null;
}
@ -245,10 +277,28 @@ function ClaimMenuList(props: Props) {
{/* COLLECTION OPERATIONS */}
{collectionId && isCollectionClaim ? (
<>
<MenuItem className="comment__menu-option" onSelect={() => push(`/$/${PAGES.LIST}/${collectionId}`)}>
<div className="menu__link">
<MenuItem
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} />
{__('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,33 @@ import {
import { makeSelectCostInfoForUri } from 'lbryinc';
import { doToast } from 'redux/actions/notifications';
import { doOpenModal } from 'redux/actions/app';
import { selectListShuffle } from 'redux/selectors/content';
import { doSetPlayingUri, doToggleShuffleList } 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) => {
const collectionId = props.collectionId;
const shuffleList = selectListShuffle(state);
const shuffle = shuffleList && shuffleList.collectionId === collectionId && shuffleList.newUrls;
const playNextUri = shuffle && shuffle[0];
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(props.collectionId)(state),
collectionHasEdits: Boolean(makeSelectEditedCollectionForId(props.collectionId)(state)),
playNextUri,
};
};
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
doToast: (options) => dispatch(doToast(options)),
doSetPlayingUri: (uri) => dispatch(doSetPlayingUri({ uri })),
doToggleShuffleList: (collectionId) => dispatch(doToggleShuffleList(undefined, collectionId, true, true)),
});
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,10 @@ type Props = {
showInfo: boolean,
setShowInfo: (boolean) => void,
collectionHasEdits: boolean,
doToggleShuffleList: (string) => void,
playNextUri: string,
doSetPlayingUri: (string) => void,
isBuiltin: boolean,
};
function CollectionActions(props: Props) {
@ -37,61 +43,92 @@ function CollectionActions(props: Props) {
showInfo,
setShowInfo,
collectionHasEdits,
doToggleShuffleList,
playNextUri,
doSetPlayingUri,
isBuiltin,
} = 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?
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 = (
<>
{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.SHUFFLE}
label={__('Shuffle Play')}
title={__('Shuffle Play')}
onClick={() => {
doToggleShuffleList(collectionId);
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

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

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

View file

@ -1,14 +1,22 @@
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, doSetPlayingUri, doPlayUri } from 'redux/actions/content';
import { makeSelectContentPositionForUri, selectPlayingUri, makeSelectIsPlayerFloating } 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) => {
@ -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)
const position = urlParams.get('t') !== null ? urlParams.get('t') : makeSelectContentPositionForUri(props.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 playNextUri;
let playPreviousUri;
if (collectionId) {
playNextUri = makeSelectNextUrlForCollectionAndUrl(collectionId, props.uri)(state);
playPreviousUri = makeSelectPreviousUrlForCollectionAndUrl(collectionId, props.uri)(state);
}
return {
autoplayIfEmbedded: Boolean(autoplay),
@ -33,6 +50,11 @@ const select = (state, props) => {
authenticated: selectUserVerifiedEmail(state),
userId: userId,
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)),
claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()),
toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()),
toggleAutoplayNext: () => dispatch(toggleAutoplayNext()),
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));

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
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', {
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,
setStartPlayPrevious: (boolean) => void,
setStartPlayNext: (boolean) => void,
};
// type VideoJSOptions = {
@ -103,6 +108,20 @@ const SMALL_J_KEYCODE = 74;
const SMALL_K_KEYCODE = 75;
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 MUTE_KEYCODE = SMALL_M_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) {
const {
autoplay,
autoplaySetting,
startMuted,
source,
sourceType,
@ -197,6 +217,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
userId,
// allowPreRoll,
shareTelemetry,
replay,
videoTheaterMode,
setStartPlayPrevious,
setStartPlayNext,
} = props;
const [reload, setReload] = useState('initial');
@ -323,13 +347,11 @@ export default React.memo<Props>(function VideoJs(props: Props) {
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)'));
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);
@ -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.keyCode === PERIOD_KEYCODE) changePlaybackSpeed(true);
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) {
@ -397,19 +421,37 @@ export default React.memo<Props>(function VideoJs(props: Props) {
if (e.keyCode === VOLUME_UP_KEYCODE) volumeUp();
if (e.keyCode === VOLUME_DOWN_KEYCODE) volumeDown();
if (e.keyCode === THEATER_MODE_KEYCODE) toggleTheaterMode();
if (e.keyCode === SEEK_FORWARD_KEYCODE) seekVideo(SEEK_STEP);
if (e.keyCode === SEEK_BACKWARD_KEYCODE) seekVideo(-SEEK_STEP);
if (e.keyCode === SEEK_FORWARD_KEYCODE_5) seekVideo(SEEK_STEP_5);
if (e.keyCode === SEEK_BACKWARD_KEYCODE_5) seekVideo(-SEEK_STEP_5);
if (e.keyCode === SEEK_FORWARD_KEYCODE) seekVideo(SEEK_STEP, false);
if (e.keyCode === SEEK_BACKWARD_KEYCODE) seekVideo(-SEEK_STEP, false);
if (e.keyCode === SEEK_FORWARD_KEYCODE_5) seekVideo(SEEK_STEP_5, false);
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 videoNode = containerRef.current && containerRef.current.querySelector('video, audio');
if (!videoNode || !player) return;
const duration = videoNode.duration;
const numberedStepSize = duration / 10;
const currentTime = videoNode.currentTime;
const newDuration = currentTime + stepSize;
let newDuration;
if (numberedStep) {
newDuration = numberedStepSize * stepSize;
} else {
newDuration = currentTime + stepSize;
}
if (newDuration < 0) {
videoNode.currentTime = 0;
} else if (newDuration > duration) {
@ -417,7 +459,10 @@ export default React.memo<Props>(function VideoJs(props: Props) {
} else {
videoNode.currentTime = newDuration;
}
OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0);
if (!numberedStep) {
OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0);
}
player.userActive(true);
}
@ -580,7 +625,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
@ -601,6 +647,41 @@ export default React.memo<Props>(function VideoJs(props: Props) {
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.
useEffect(() => {
const vjsElement = createVideoPlayerDOM(containerRef.current);

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;
@ -47,11 +52,19 @@ type Props = {
savePosition: (string, number) => void,
clearPosition: (string) => void,
toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void,
doSetPlayingUri: (string, string) => void,
doPlayUri: (string) => void,
playNextUri: string,
playPreviousUri: string,
authenticated: boolean,
userId: number,
homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean,
videoTheaterMode: boolean,
collectionId: string,
isFloating: boolean,
};
/*
@ -81,11 +94,19 @@ function VideoViewer(props: Props) {
clearPosition,
desktopPlayStartTime,
toggleVideoTheaterMode,
toggleAutoplayNext,
setVideoPlaybackRate,
doSetPlayingUri,
doPlayUri,
playNextUri,
playPreviousUri,
homepageData,
authenticated,
userId,
shareTelemetry,
videoTheaterMode,
collectionId,
isFloating,
} = props;
const adApprovedChannelIds = homepageData ? getAllIds(homepageData) : [];
@ -95,8 +116,10 @@ function VideoViewer(props: Props) {
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
const {
location: { pathname },
push,
} = useHistory();
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 +131,26 @@ 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 [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)
useEffect(() => {
@ -126,6 +169,51 @@ function VideoViewer(props: Props) {
};
}, [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) {
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
data.playerPoweredBy = response.headers.get('x-powered-by');
@ -152,28 +240,41 @@ function VideoViewer(props: Props) {
});
}
const onEnded = React.useCallback(() => {
analytics.videoIsPlaying(false);
React.useEffect(() => {
if (ended) {
analytics.videoIsPlaying(false);
if (adUrl) {
setAdUrl(null);
return;
if (adUrl) {
setAdUrl(null);
return;
}
if (embedded) {
setIsEndededEmbed(true);
} else if (autoplaySetting) {
setShowAutoplayCountdown(true);
}
clearPosition(uri);
}
if (embedded) {
setIsEndededEmbed(true);
} else if (autoplaySetting) {
setShowAutoplayCountdown(true);
}
clearPosition(uri);
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl, clearPosition, uri]);
}, [
embedded,
setIsEndededEmbed,
autoplaySetting,
setShowAutoplayCountdown,
adUrl,
setAdUrl,
clearPosition,
uri,
ended,
]);
function onPlay(player) {
setIsLoading(false);
setIsPlaying(true);
setShowAutoplayCountdown(false);
setIsEndededEmbed(false);
setReplay(false);
analytics.videoIsPlaying(true, player);
}
@ -203,12 +304,19 @@ function VideoViewer(props: Props) {
playerReadyDependencyList.push(desktopPlayStartTime);
}
const onPlayerReady = useCallback((player: Player) => {
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, () => setStartPlayNext(true));
addPlayPreviousButton(player, () => setStartPlayPrevious(true));
} else {
addAutoplayNextButton(player, toggleAutoplayNext, autoplaySetting);
}
}
const shouldPlay = !embedded || autoplayIfEmbedded;
@ -244,7 +352,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 +393,7 @@ function VideoViewer(props: Props) {
})}
onContextMenu={stopContextMenu}
>
{showAutoplayCountdown && <AutoplayCountdown uri={uri} />}
{showAutoplayCountdown && <AutoplayCountdown uri={uri} setReplay={setReplay} />}
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{/* disable this loading behavior because it breaks when player.play() promise hangs */}
@ -332,10 +440,15 @@ function VideoViewer(props: Props) {
startMuted={autoplayIfEmbedded}
toggleVideoTheaterMode={toggleVideoTheaterMode}
autoplay={!embedded || autoplayIfEmbedded}
autoplaySetting={autoplaySetting}
claimId={claimId}
userId={userId}
allowPreRoll={!embedded && !authenticated}
shareTelemetry={shareTelemetry}
replay={replay}
videoTheaterMode={videoTheaterMode}
setStartPlayNext={setStartPlayNext}
setStartPlayPrevious={setStartPlayPrevious}
/>
)}
</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

@ -169,3 +169,6 @@ export const CONTENT = 'Content';
export const STAR = 'star';
export const MUSIC = 'MusicCategory';
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>
) : (
<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

@ -112,18 +112,26 @@ 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}
/>
}
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)(state);
dispatch(doSetClientSetting(SETTINGS.AUTOPLAY, !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

@ -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,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 {
color: var(--color-link);
transition: color 0.2s;

View file

@ -314,6 +314,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

@ -10129,9 +10129,9 @@ lazy-val@^1.0.4:
yargs "^13.2.2"
zstd-codec "^0.1.1"
lbry-redux@lbryio/lbry-redux#e4d0662100a5f4b28bb1bf3cbc1e51b2eebab5b6:
lbry-redux@lbryio/lbry-redux#aeb1f533b590c0a9a63954beaa52e92d6f0668e3:
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:
proxy-polyfill "0.1.6"
reselect "^3.0.0"