Add persistent watch time setting. (#7547)

* Add persistent watch time setting.

* floating bugfix --jessopb

* Improve how the persist watch time is being stored; add clear cache button.

* Add makeSelectContentWatchedPercentageForUri selector and give feedback when clearing cache.

* tweaks --jessopb

Co-authored-by: zeppi <jessopb@gmail.com>
This commit is contained in:
Franco Montenegro 2022-04-22 00:00:57 -03:00 committed by GitHub
parent 3854bf6fd4
commit ee754f0085
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 144 additions and 28 deletions

View file

@ -2300,7 +2300,6 @@
"In %collection%": "In %collection%", "In %collection%": "In %collection%",
"Add to %collection%": "Add to %collection%", "Add to %collection%": "Add to %collection%",
"Show this channel your appreciation by sending a donation of Credits. ": "Show this channel your appreciation by sending a donation of Credits. ", "Show this channel your appreciation by sending a donation of Credits. ": "Show this channel your appreciation by sending a donation of Credits. ",
"%action% %collection%": "%action% %collection%",
"You've entered the land of content freedom! Let's make sure everything is ship shape.": "You've entered the land of content freedom! Let's make sure everything is ship shape.", "You've entered the land of content freedom! Let's make sure everything is ship shape.": "You've entered the land of content freedom! Let's make sure everything is ship shape.",
"By continuing, you agree to the %terms%": "By continuing, you agree to the %terms%", "By continuing, you agree to the %terms%": "By continuing, you agree to the %terms%",
"Privacy": "Privacy", "Privacy": "Privacy",
@ -2308,21 +2307,13 @@
"Yes, share with LBRY": "Yes, share with LBRY", "Yes, share with LBRY": "Yes, share with LBRY",
"Search Uploads": "Search Uploads", "Search Uploads": "Search Uploads",
"This refundable boost will improve the discoverability of this %claimTypeText% while active. ": "This refundable boost will improve the discoverability of this %claimTypeText% while active. ", "This refundable boost will improve the discoverability of this %claimTypeText% while active. ": "This refundable boost will improve the discoverability of this %claimTypeText% while active. ",
"%repost_channel_link%": "%repost_channel_link%",
"Show less": "Show less", "Show less": "Show less",
"Channel \"realporno\" blocked.": "Channel \"realporno\" blocked.",
"Elements": "Elements", "Elements": "Elements",
"Icons": "Icons", "Icons": "Icons",
"You followed @MinutePhysics!": "You followed @MinutePhysics!",
"Unfollowed @samtime.": "Unfollowed @samtime.",
"You followed @samtime!": "You followed @samtime!",
"Unfollowed @gatogalactico.": "Unfollowed @gatogalactico.",
"You followed @gatogalactico!": "You followed @gatogalactico!",
"Unfollowed @Odysee.": "Unfollowed @Odysee.",
"Unfollowed @rossmanngroup.": "Unfollowed @rossmanngroup.",
"You followed @rossmanngroup!": "You followed @rossmanngroup!",
"%repost% %publish%": "%repost% %publish%",
"Failed to view lbry://@MicheL-PDF#7/LaDameAuPain#f, please try again. If this problem persists, visit https://lbry.com/faq/support for support.": "Failed to view lbry://@MicheL-PDF#7/LaDameAuPain#f, please try again. If this problem persists, visit https://lbry.com/faq/support for support.",
"Go to": "Go to", "Go to": "Go to",
"Clearing...": "Clearing...",
"Clear Views": "Clear Views",
"Show Video View Progress": "Show Video View Progress",
"Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.": "Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.",
"--end--": "--end--" "--end--": "--end--"
} }

View file

@ -1,12 +1,17 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { makeSelectClaimForUri } from 'redux/selectors/claims'; import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { makeSelectContentPositionForUri } from 'redux/selectors/content'; import { makeSelectContentWatchedPercentageForUri } from 'redux/selectors/content';
import CardMedia from './view'; import CardMedia from './view';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import * as SETTINGS from 'constants/settings';
const select = (state, props) => ({ const select = (state, props) => {
position: makeSelectContentPositionForUri(props.uri)(state), return {
watchedPercentage: makeSelectContentWatchedPercentageForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state), claim: makeSelectClaimForUri(props.uri)(state),
}); showPercentage: makeSelectClientSetting(SETTINGS.PERSIST_WATCH_TIME)(state),
};
};
export default connect(select, { doResolveUri })(CardMedia); export default connect(select, { doResolveUri })(CardMedia);

View file

@ -16,11 +16,22 @@ type Props = {
claim: ?StreamClaim, claim: ?StreamClaim,
doResolveUri: (string) => void, doResolveUri: (string) => void,
className?: string, className?: string,
position?: number, watchedPercentage: number,
showPercentage: boolean,
}; };
function FileThumbnail(props: Props) { function FileThumbnail(props: Props) {
const { claim, uri, doResolveUri, thumbnail: rawThumbnail, children, allowGifs = false, className, position } = props; const {
claim,
uri,
doResolveUri,
thumbnail: rawThumbnail,
children,
allowGifs = false,
className,
watchedPercentage,
showPercentage,
} = props;
const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://'); const passedThumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
const thumbnailFromClaim = const thumbnailFromClaim =
@ -30,11 +41,9 @@ function FileThumbnail(props: Props) {
const hasResolvedClaim = claim !== undefined; const hasResolvedClaim = claim !== undefined;
const isGif = thumbnail && thumbnail.endsWith('gif'); const isGif = thumbnail && thumbnail.endsWith('gif');
const media = claim && claim.value && (claim.value.video || claim.value.audio); const viewedBar = showPercentage && watchedPercentage && (
const duration = media && media.duration;
const viewedBar = position && duration && (
<div className="file-thumbnail__viewed-bar"> <div className="file-thumbnail__viewed-bar">
<div className="file-thumbnail__viewed-bar-progress" style={{ width: (position / duration) * 100 + '%' }} /> <div className="file-thumbnail__viewed-bar-progress" style={{ width: `${watchedPercentage}%` }} />
</div> </div>
); );

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectMyChannelUrls } from 'redux/selectors/claims'; import { selectMyChannelUrls } from 'redux/selectors/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { doSetPlayingUri } from 'redux/actions/content'; import { doSetPlayingUri, clearContentCache } from 'redux/actions/content';
import { doSetClientSetting } from 'redux/actions/settings'; import { doSetClientSetting } from 'redux/actions/settings';
import { selectShowMatureContent, makeSelectClientSetting } from 'redux/selectors/settings'; import { selectShowMatureContent, makeSelectClientSetting } from 'redux/selectors/settings';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -14,6 +14,7 @@ const select = (state) => ({
autoplayMedia: makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state), autoplayMedia: makeSelectClientSetting(SETTINGS.AUTOPLAY_MEDIA)(state),
autoplayNext: makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state), autoplayNext: makeSelectClientSetting(SETTINGS.AUTOPLAY_NEXT)(state),
hideReposts: makeSelectClientSetting(SETTINGS.HIDE_REPOSTS)(state), hideReposts: makeSelectClientSetting(SETTINGS.HIDE_REPOSTS)(state),
persistWatchTime: makeSelectClientSetting(SETTINGS.PERSIST_WATCH_TIME)(state),
showNsfw: selectShowMatureContent(state), showNsfw: selectShowMatureContent(state),
myChannelUrls: selectMyChannelUrls(state), myChannelUrls: selectMyChannelUrls(state),
instantPurchaseEnabled: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_ENABLED)(state), instantPurchaseEnabled: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_ENABLED)(state),
@ -24,6 +25,7 @@ const select = (state) => ({
const perform = (dispatch) => ({ const perform = (dispatch) => ({
setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)), setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)),
clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })), clearPlayingUri: () => dispatch(doSetPlayingUri({ uri: null })),
clearContentCache: () => dispatch(clearContentCache()),
}); });
export default connect(select, perform)(SettingContent); export default connect(select, perform)(SettingContent);

View file

@ -24,6 +24,7 @@ type Props = {
autoplayNext: boolean, autoplayNext: boolean,
hideReposts: ?boolean, hideReposts: ?boolean,
showNsfw: boolean, showNsfw: boolean,
persistWatchTime: boolean,
myChannelUrls: ?Array<string>, myChannelUrls: ?Array<string>,
instantPurchaseEnabled: boolean, instantPurchaseEnabled: boolean,
instantPurchaseMax: Price, instantPurchaseMax: Price,
@ -31,6 +32,7 @@ type Props = {
// --- perform --- // --- perform ---
setClientSetting: (string, boolean | string | number) => void, setClientSetting: (string, boolean | string | number) => void,
clearPlayingUri: () => void, clearPlayingUri: () => void,
// clearContentCache: () => void,
}; };
export default function SettingContent(props: Props) { export default function SettingContent(props: Props) {
@ -40,6 +42,7 @@ export default function SettingContent(props: Props) {
autoplayMedia, autoplayMedia,
autoplayNext, autoplayNext,
hideReposts, hideReposts,
persistWatchTime,
showNsfw, showNsfw,
myChannelUrls, myChannelUrls,
instantPurchaseEnabled, instantPurchaseEnabled,
@ -47,7 +50,21 @@ export default function SettingContent(props: Props) {
enablePublishPreview, enablePublishPreview,
setClientSetting, setClientSetting,
clearPlayingUri, clearPlayingUri,
// clearContentCache,
} = props; } = props;
// feature disabled until styling is ironed out
// const [contentCacheCleared, setContentCacheCleared] = React.useState(false);
// const [clearingContentCache, setClearingContentCache] = React.useState(false);
// const onClearContentCache = React.useCallback(() => {
// setClearingContentCache(true);
// clearContentCache();
// // Just a small timer to give the user a visual effect
// // that the content is being cleared.
// setTimeout(() => {
// setClearingContentCache(false);
// setContentCacheCleared(true);
// }, 2000);
// }, [setClearingContentCache, clearContentCache, setContentCacheCleared]);
return ( return (
<> <>
@ -102,6 +119,32 @@ export default function SettingContent(props: Props) {
checked={hideReposts} checked={hideReposts}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow title={__('Show Video View Progress')} subtitle={__(HELP.PERSIST_WATCH_TIME)}>
{/* <div className="settings__persistWatchTimeCheckbox"> */}
<FormField
type="checkbox"
name="persist_watch_time"
onChange={() => setClientSetting(SETTINGS.PERSIST_WATCH_TIME, !persistWatchTime)}
checked={persistWatchTime}
/>
{/* </div>
Disabled until styling is better
<div className="settings__persistWatchTimeClearCache">
<Button
button="primary"
icon={ICONS.ALERT}
label={
contentCacheCleared
? __('Views cleared')
: clearingContentCache
? __('Clearing...')
: __('Clear Views')
}
onClick={onClearContentCache}
disabled={clearingContentCache || contentCacheCleared}
/>
</div> */}
</SettingsRow>
<SettingsRow title={__('Show mature content')} subtitle={__(HELP.SHOW_MATURE)}> <SettingsRow title={__('Show mature content')} subtitle={__(HELP.SHOW_MATURE)}>
<FormField <FormField
type="checkbox" type="checkbox"
@ -188,6 +231,7 @@ const HELP = {
AUTOPLAY_MEDIA: 'Autoplay video and audio files when navigating to a file.', AUTOPLAY_MEDIA: 'Autoplay video and audio files when navigating to a file.',
AUTOPLAY_NEXT: 'Autoplay the next related item when a file (video or audio) finishes playing.', AUTOPLAY_NEXT: 'Autoplay the next related item when a file (video or audio) finishes playing.',
HIDE_REPOSTS: 'You will not see reposts by people you follow or receive email notifying about them.', HIDE_REPOSTS: 'You will not see reposts by people you follow or receive email notifying about them.',
PERSIST_WATCH_TIME: 'Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.',
SHOW_MATURE: 'Mature content may include nudity, intense sexuality, profanity, or other adult content. By displaying mature content, you are affirming you are of legal age to view mature content in your country or jurisdiction. ', SHOW_MATURE: 'Mature content may include nudity, intense sexuality, profanity, or other adult content. By displaying mature content, you are affirming you are of legal age to view mature content in your country or jurisdiction. ',
MAX_PURCHASE_PRICE: 'This will prevent you from purchasing any content over a certain cost, as a safety measure.', MAX_PURCHASE_PRICE: 'This will prevent you from purchasing any content over a certain cost, as a safety measure.',
ONLY_CONFIRM_OVER_AMOUNT: '', // [feel redundant. Disable for now] "When this option is chosen, LBRY won't ask you to confirm purchases or tips below your chosen amount.", ONLY_CONFIRM_OVER_AMOUNT: '', // [feel redundant. Disable for now] "When this option is chosen, LBRY won't ask you to confirm purchases or tips below your chosen amount.",

View file

@ -270,8 +270,12 @@ function VideoViewer(props: Props) {
} }
function handlePosition(player) { function handlePosition(player) {
const currTime = player.currentTime();
const durationInSeconds = claim.value.video && claim.value.video.duration;
if (Number(durationInSeconds) > Number(currTime)) {
savePosition(uri, player.currentTime()); savePosition(uri, player.currentTime());
} }
}
function restorePlaybackRate(player) { function restorePlaybackRate(player) {
if (!vjsCallbackDataRef.current.embedded) { if (!vjsCallbackDataRef.current.embedded) {

View file

@ -143,6 +143,7 @@ export const SET_PRIMARY_URI = 'SET_PRIMARY_URI';
export const SET_PLAYING_URI = 'SET_PLAYING_URI'; export const SET_PLAYING_URI = 'SET_PLAYING_URI';
export const SET_CONTENT_POSITION = 'SET_CONTENT_POSITION'; export const SET_CONTENT_POSITION = 'SET_CONTENT_POSITION';
export const CLEAR_CONTENT_POSITION = 'CLEAR_CONTENT_POSITION'; export const CLEAR_CONTENT_POSITION = 'CLEAR_CONTENT_POSITION';
export const CLEAR_CONTENT_CACHE = 'CLEAR_CONTENT_CACHE';
export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED'; export const SET_CONTENT_LAST_VIEWED = 'SET_CONTENT_LAST_VIEWED';
export const CLEAR_CONTENT_HISTORY_URI = 'CLEAR_CONTENT_HISTORY_URI'; 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';

View file

@ -35,6 +35,7 @@ export const REWARDS_ACKNOWLEDGED = 'rewards_acknowledged';
export const SEARCH_IN_LANGUAGE = 'search_in_language'; export const SEARCH_IN_LANGUAGE = 'search_in_language';
export const HOMEPAGE = 'homepage'; export const HOMEPAGE = 'homepage';
export const HIDE_REPOSTS = 'hide_reposts'; export const HIDE_REPOSTS = 'hide_reposts';
export const PERSIST_WATCH_TIME = 'persist_watch_time';
export const SUPPORT_OPTION = 'support_option'; export const SUPPORT_OPTION = 'support_option';
export const TILE_LAYOUT = 'tile_layout'; export const TILE_LAYOUT = 'tile_layout';
export const VIDEO_THEATER_MODE = 'video_theater_mode'; export const VIDEO_THEATER_MODE = 'video_theater_mode';

View file

@ -239,9 +239,18 @@ export function clearPosition(uri: string) {
return (dispatch: Dispatch, getState: () => any) => { return (dispatch: Dispatch, getState: () => any) => {
const state = getState(); const state = getState();
const claim = makeSelectClaimForUri(uri)(state); const claim = makeSelectClaimForUri(uri)(state);
const persistWatchTime = makeSelectClientSetting(SETTINGS.PERSIST_WATCH_TIME)(state);
const { claim_id: claimId, txid, nout } = claim; const { claim_id: claimId, txid, nout } = claim;
const outpoint = `${txid}:${nout}`; const outpoint = `${txid}:${nout}`;
if (persistWatchTime) {
dispatch({
type: ACTIONS.SET_CONTENT_POSITION,
data: { claimId, outpoint, position: null },
});
return;
}
dispatch({ dispatch({
type: ACTIONS.CLEAR_CONTENT_POSITION, type: ACTIONS.CLEAR_CONTENT_POSITION,
data: { claimId, outpoint }, data: { claimId, outpoint },
@ -249,6 +258,12 @@ export function clearPosition(uri: string) {
}; };
} }
export function clearContentCache() {
return {
type: ACTIONS.CLEAR_CONTENT_CACHE,
};
}
export function doSetContentHistoryItem(uri: string) { export function doSetContentHistoryItem(uri: string) {
return (dispatch: Dispatch) => { return (dispatch: Dispatch) => {
dispatch({ dispatch({

View file

@ -90,6 +90,13 @@ reducers[ACTIONS.CLEAR_CONTENT_POSITION] = (state, action) => {
} }
}; };
reducers[ACTIONS.CLEAR_CONTENT_CACHE] = (state, action) => {
return {
...state,
positions: {},
};
};
reducers[ACTIONS.SET_CONTENT_LAST_VIEWED] = (state, action) => { reducers[ACTIONS.SET_CONTENT_LAST_VIEWED] = (state, action) => {
const { uri, lastViewed } = action.data; const { uri, lastViewed } = action.data;
const { history } = state; const { history } = state;

View file

@ -67,6 +67,7 @@ const defaultState = {
[SETTINGS.FLOATING_PLAYER]: true, [SETTINGS.FLOATING_PLAYER]: true,
[SETTINGS.AUTO_DOWNLOAD]: true, [SETTINGS.AUTO_DOWNLOAD]: true,
[SETTINGS.HIDE_REPOSTS]: false, [SETTINGS.HIDE_REPOSTS]: false,
[SETTINGS.PERSIST_WATCH_TIME]: true,
// OS // OS
[SETTINGS.AUTO_LAUNCH]: true, [SETTINGS.AUTO_LAUNCH]: true,

View file

@ -63,6 +63,31 @@ export const makeSelectContentPositionForUri = (uri: string) =>
return state.positions[id] ? state.positions[id][outpoint] : null; return state.positions[id] ? state.positions[id][outpoint] : null;
}); });
export const makeSelectContentWatchedPercentageForUri = (uri: string) =>
createSelector(selectState, makeSelectClaimForUri(uri), (state, claim) => {
if (!claim) {
return 0;
}
const media = claim.value && (claim.value.video || claim.value.audio);
if (!media) {
return 0;
}
const id = claim.claim_id;
if (!state.positions[id]) {
return 0;
}
const outpoint = `${claim.txid}:${claim.nout}`;
const watched = state.positions[id][outpoint];
// If the user turns on the persist watch setting,
// clearing the position will set it to null,
// which means the entire video has been watched.
if (watched === null) {
return 100;
}
const duration = media.duration;
return (watched / duration) * 100;
});
export const selectHistory = createSelector(selectState, (state) => state.history || []); export const selectHistory = createSelector(selectState, (state) => state.history || []);
export const selectHistoryPageCount = createSelector(selectHistory, (history) => export const selectHistoryPageCount = createSelector(selectHistory, (history) =>

View file

@ -4,10 +4,10 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 5px; height: 5px;
background-color: gray; background-color: var(--color-gray-7);
} }
.file-thumbnail__viewed-bar-progress { .file-thumbnail__viewed-bar-progress {
height: 5px; height: 5px;
background-color: red; background-color: var(--color-primary);
} }

View file

@ -18,3 +18,14 @@
margin-left: 2.2rem; margin-left: 2.2rem;
} }
} }
.settings__persistWatchTimeCheckbox {
display: flex;
justify-content: end;
padding-bottom: var(--spacing-m);
}
.settings__persistWatchTimeCheckbox {
text-align: right;
padding-top: var(--spacing-m);
}