diff --git a/ui/component/settingContent/view.jsx b/ui/component/settingContent/view.jsx index ed785823a..d23abf79e 100644 --- a/ui/component/settingContent/view.jsx +++ b/ui/component/settingContent/view.jsx @@ -12,6 +12,7 @@ import Card from 'component/common/card'; import { FormField, FormFieldPrice } from 'component/common/form'; import MaxPurchasePrice from 'component/maxPurchasePrice'; import SettingsRow from 'component/settingsRow'; +import SettingDefaultQuality from 'component/settingDefaultQuality'; type Price = { currency: string, @@ -119,6 +120,10 @@ export default function SettingContent(props: Props) { /> + + + + {!SIMPLE_SITE && ( <> {/* @@ -240,4 +245,5 @@ const HELP = { 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.", PUBLISH_PREVIEW: 'Show preview and confirmation dialog before publishing content.', + DEFAULT_VIDEO_QUALITY: 'Set a default quality for video playback. If the default choice is not available, the next lowest will be used when playback starts.', }; diff --git a/ui/component/settingDefaultQuality/index.js b/ui/component/settingDefaultQuality/index.js new file mode 100644 index 000000000..0cd94c875 --- /dev/null +++ b/ui/component/settingDefaultQuality/index.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import * as SETTINGS from 'constants/settings'; +import { doSetDefaultVideoQuality } from 'redux/actions/settings'; +import { selectClientSetting } from 'redux/selectors/settings'; +import SettingDefaultQuality from './view'; + +const select = (state) => ({ + defaultQuality: selectClientSetting(state, SETTINGS.DEFAULT_VIDEO_QUALITY), +}); + +const perform = { + doSetDefaultVideoQuality, +}; + +export default connect(select, perform)(SettingDefaultQuality); diff --git a/ui/component/settingDefaultQuality/view.jsx b/ui/component/settingDefaultQuality/view.jsx new file mode 100644 index 000000000..7bfa9bed1 --- /dev/null +++ b/ui/component/settingDefaultQuality/view.jsx @@ -0,0 +1,70 @@ +// @flow +import React from 'react'; +import { FormField } from 'component/common/form'; +import { VIDEO_QUALITY_OPTIONS } from 'constants/video'; +import { toCapitalCase } from 'util/string'; + +type Props = { + defaultQuality: ?string, + doSetDefaultVideoQuality: (value: ?string) => void, +}; + +export default function SettingDefaultQuality(props: Props) { + const { defaultQuality, doSetDefaultVideoQuality } = props; + + const [enabled, setEnabled] = React.useState(Boolean(defaultQuality)); + + const valueRef = React.useRef(VIDEO_QUALITY_OPTIONS[0]); + + function handleEnable() { + if (enabled) { + setEnabled(false); + // From enabled to disabled -> clear the setting + doSetDefaultVideoQuality(null); + } else { + setEnabled(true); + // From to disabled to enabled -> set the current shown value + doSetDefaultVideoQuality(valueRef.current); + } + } + + function handleSetQuality(e) { + const { value } = e.target; + doSetDefaultVideoQuality(value); + valueRef.current = value; + } + + return ( + <> + + + {VIDEO_QUALITY_OPTIONS.map((quality) => { + const qualityStr = typeof quality === 'number' ? quality + 'p' : toCapitalCase(quality); + + return ( + + ); + })} + + + + + + + + ); +} diff --git a/ui/component/viewers/videoViewer/index.js b/ui/component/viewers/videoViewer/index.js index dc1a6f3d3..22d8435e3 100644 --- a/ui/component/viewers/videoViewer/index.js +++ b/ui/component/viewers/videoViewer/index.js @@ -23,7 +23,12 @@ import VideoViewer from './view'; import { withRouter } from 'react-router'; import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards'; import { selectDaemonSettings, selectClientSetting, selectHomepageData } from 'redux/selectors/settings'; -import { toggleVideoTheaterMode, toggleAutoplayNext, doSetClientSetting } from 'redux/actions/settings'; +import { + toggleVideoTheaterMode, + toggleAutoplayNext, + doSetClientSetting, + doSetDefaultVideoQuality, +} from 'redux/actions/settings'; import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; const select = (state, props) => { @@ -74,6 +79,7 @@ const select = (state, props) => { videoTheaterMode: selectClientSetting(state, SETTINGS.VIDEO_THEATER_MODE), activeLivestreamForChannel: selectActiveLivestreamForChannel(state, getChannelIdFromClaim(claim)), isLivestreamClaim: isStreamPlaceholderClaim(claim), + defaultQuality: selectClientSetting(state, SETTINGS.DEFAULT_VIDEO_QUALITY), }; }; @@ -101,6 +107,7 @@ const perform = (dispatch) => ({ ), doAnalyticsView: (uri, timeToStart) => dispatch(doAnalyticsView(uri, timeToStart)), claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()), + doSetDefaultVideoQuality: (value) => dispatch(doSetDefaultVideoQuality(value)), }); export default withRouter(connect(select, perform)(VideoViewer)); diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js index 2f4085c5b..1f85fa8f3 100644 --- a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/ConcreteButton.js @@ -1,24 +1,11 @@ import videojs from 'video.js'; +import { toCapitalCase } from 'util/string'; const VideoJsButtonClass = videojs.getComponent('MenuButton'); const VideoJsMenuClass = videojs.getComponent('Menu'); const VideoJsComponent = videojs.getComponent('Component'); const Dom = videojs.dom; -/** - * Convert string to title case. - * - * @param {string} string - the string to convert - * @return {string} the returned titlecase string - */ -function toTitleCase(string) { - if (typeof string !== 'string') { - return string; - } - - return string.charAt(0).toUpperCase() + string.slice(1); -} - /** * Extend vjs button class for quality button. */ @@ -59,7 +46,7 @@ export default class ConcreteButton extends VideoJsButtonClass { if (this.options_.title) { const titleEl = Dom.createEl('li', { className: 'vjs-menu-title', - innerHTML: toTitleCase(this.options_.title), + innerHTML: toCapitalCase(this.options_.title), tabIndex: -1, }); const titleComponent = new VideoJsComponent(this.player_, { el: titleEl }); diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js index c222ed21c..e7b3fe929 100644 --- a/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-hls-quality-selector/plugin.js @@ -3,6 +3,7 @@ import videojs from 'video.js'; import { version as VERSION } from './package.json'; import ConcreteButton from './ConcreteButton'; import ConcreteMenuItem from './ConcreteMenuItem'; +import * as QUALITY_OPTIONS from 'constants/video'; // Default options for the plugin. const defaults = {}; @@ -104,7 +105,7 @@ class HlsQualitySelectorPlugin { concreteButtonInstance.menuButton_.$('.vjs-icon-placeholder').className += icon; } else { - this.setButtonInnerText('auto'); + this.setButtonInnerText(QUALITY_OPTIONS.AUTO); } concreteButtonInstance.removeClass('vjs-hidden'); } @@ -134,10 +135,10 @@ class HlsQualitySelectorPlugin { setButtonInnerText(text) { let str; switch (text) { - case 'auto': + case QUALITY_OPTIONS.AUTO: str = __('Auto --[Video quality. Short form]--'); break; - case 'original': + case QUALITY_OPTIONS.ORIGINAL: str = this.resolveOriginalQualityLabel(true, false); break; default: @@ -165,25 +166,45 @@ class HlsQualitySelectorPlugin { */ onAddQualityLevel() { const player = this.player; + const { defaultQuality } = this.config; const qualityList = player.qualityLevels(); const levels = qualityList.levels_ || []; - const levelItems = []; + + let levelItems = []; + let nextLowestQualityItem; + let nextLowestQualityItemObj; for (let i = 0; i < levels.length; ++i) { - if ( - !levelItems.filter((_existingItem) => { - return _existingItem.item && _existingItem.item.value === levels[i].height; - }).length - ) { + const currentHeight = levels[i].height; + + if (!levelItems.filter((_existingItem) => _existingItem.item?.value === currentHeight).length) { + const heightStr = currentHeight + 'p'; + const levelItem = this.getQualityMenuItem.call(this, { - label: levels[i].height + 'p', - value: levels[i].height, + label: heightStr, + value: currentHeight, + selected: defaultQuality ? currentHeight === defaultQuality : undefined, }); + if (defaultQuality && !nextLowestQualityItem && currentHeight <= defaultQuality) { + nextLowestQualityItem = levelItem; + nextLowestQualityItemObj = { + label: heightStr, + value: currentHeight, + selected: true, + }; + } + levelItems.push(levelItem); } } + if (nextLowestQualityItem) { + levelItems = levelItems.map((item) => + item === nextLowestQualityItem ? this.getQualityMenuItem.call(this, nextLowestQualityItemObj) : item + ); + } + levelItems.sort((current, next) => { if (typeof current !== 'object' || typeof next !== 'object') { return -1; @@ -201,8 +222,8 @@ class HlsQualitySelectorPlugin { levelItems.push( this.getQualityMenuItem.call(this, { label: this.resolveOriginalQualityLabel(false, true), - value: 'original', - selected: false, + value: QUALITY_OPTIONS.ORIGINAL, + selected: defaultQuality ? defaultQuality === QUALITY_OPTIONS.ORIGINAL : false, }) ); } @@ -210,11 +231,15 @@ class HlsQualitySelectorPlugin { levelItems.push( this.getQualityMenuItem.call(this, { label: __('Auto --[Video quality. Short form]--'), - value: 'auto', - selected: true, + value: QUALITY_OPTIONS.AUTO, + selected: !defaultQuality ? true : defaultQuality === QUALITY_OPTIONS.AUTO, }) ); + if (nextLowestQualityItemObj || defaultQuality) { + this.setButtonInnerText(nextLowestQualityItemObj ? nextLowestQualityItemObj.label : defaultQuality); + } + if (this._qualityButton) { this._qualityButton.createItems = function () { return levelItems; @@ -223,13 +248,13 @@ class HlsQualitySelectorPlugin { } } - swapSrcTo(mode = 'original') { + swapSrcTo(mode = QUALITY_OPTIONS.ORIGINAL) { const currentTime = this.player.currentTime(); this.player.src(mode === 'vhs' ? this.player.claimSrcVhs : this.player.claimSrcOriginal); this.player.load(); this.player.currentTime(currentTime); - console.assert(mode === 'vhs' || mode === 'original', 'Unexpected input'); + console.assert(mode === 'vhs' || mode === QUALITY_OPTIONS.ORIGINAL, 'Unexpected input'); } /** @@ -238,35 +263,36 @@ class HlsQualitySelectorPlugin { * @param {number} height - A number representing HLS playlist. */ setQuality(height) { + const { doSetDefaultVideoQuality } = this.config; const qualityList = this.player.qualityLevels(); // Set quality on plugin this._currentQuality = height; + doSetDefaultVideoQuality(height); if (this.config.displayCurrentQuality) { - this.setButtonInnerText(height === 'auto' ? 'auto' : height === 'original' ? 'original' : `${height}p`); + this.setButtonInnerText( + height === QUALITY_OPTIONS.AUTO + ? QUALITY_OPTIONS.AUTO + : height === QUALITY_OPTIONS.ORIGINAL + ? QUALITY_OPTIONS.ORIGINAL + : `${height}p` + ); } for (let i = 0; i < qualityList.length; ++i) { const quality = qualityList[i]; - quality.enabled = quality.height === height || height === 'auto' || height === 'original'; + quality.enabled = + quality.height === height || height === QUALITY_OPTIONS.AUTO || height === QUALITY_OPTIONS.ORIGINAL; } - if (height === 'original') { + if (height === QUALITY_OPTIONS.ORIGINAL) { if (this.player.currentSrc() !== this.player.claimSrcOriginal.src) { - setTimeout(() => this.swapSrcTo('original')); + setTimeout(() => this.swapSrcTo(QUALITY_OPTIONS.ORIGINAL)); } } else { if (!this.player.isLivestream && this.player.currentSrc() !== this.player.claimSrcVhs.src) { setTimeout(() => this.swapSrcTo('vhs')); - - if (height !== 'auto') { - // -- Re-select quality -- - // Until we have "persistent quality" implemented, we need to do this - // because the VHS internals default to "auto" when initialized, - // causing a GUI mismatch. - setTimeout(() => this.setQuality(height), 1000); - } } } @@ -279,7 +305,7 @@ class HlsQualitySelectorPlugin { * @return {string} the currently set quality */ getCurrentQuality() { - return this._currentQuality || 'auto'; + return this._currentQuality || QUALITY_OPTIONS.AUTO; } } diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index bbe3f9a83..c4674e96a 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -84,6 +84,8 @@ type Props = { startMuted: boolean, userId: ?number, videoTheaterMode: boolean, + defaultQuality: ?string, + doSetDefaultVideoQuality: (value: ?string) => void, onPlayerReady: (Player, any) => void, playNext: () => void, playPrevious: () => void, @@ -144,6 +146,8 @@ export default React.memo(function VideoJs(props: Props) { startMuted, userId, videoTheaterMode, + defaultQuality, + doSetDefaultVideoQuality, onPlayerReady, playNext, playPrevious, @@ -281,6 +285,8 @@ export default React.memo(function VideoJs(props: Props) { player.hlsQualitySelector({ displayCurrentQuality: true, originalHeight: claimValues?.video?.height, + defaultQuality, + doSetDefaultVideoQuality, }); } diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index ddd068d9d..88b969abf 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -71,6 +71,8 @@ type Props = { claimRewards: () => void, isLivestreamClaim: boolean, activeLivestreamForChannel: any, + defaultQuality: ?string, + doSetDefaultVideoQuality: (value: ?string) => void, }; /* @@ -115,6 +117,8 @@ function VideoViewer(props: Props) { isMarkdownOrComment, isLivestreamClaim, activeLivestreamForChannel, + defaultQuality, + doSetDefaultVideoQuality, } = props; const permanentUrl = claim && claim.permanent_url; @@ -512,6 +516,8 @@ function VideoViewer(props: Props) { userClaimId={claim && claim.signing_channel && claim.signing_channel.claim_id} isLivestreamClaim={isLivestreamClaim} activeLivestreamForChannel={activeLivestreamForChannel} + defaultQuality={defaultQuality} + doSetDefaultVideoQuality={doSetDefaultVideoQuality} /> ); diff --git a/ui/constants/settings.js b/ui/constants/settings.js index 361fd0424..d116c3c55 100644 --- a/ui/constants/settings.js +++ b/ui/constants/settings.js @@ -43,6 +43,7 @@ export const TILE_LAYOUT = 'tile_layout'; export const VIDEO_THEATER_MODE = 'video_theater_mode'; export const VIDEO_PLAYBACK_RATE = 'video_playback_rate'; export const PREFERRED_CURRENCY = 'preferred_currency'; +export const DEFAULT_VIDEO_QUALITY = 'default_video_quality'; export const SETTINGS_GRP = { APPEARANCE: 'appearance', diff --git a/ui/constants/video.js b/ui/constants/video.js new file mode 100644 index 000000000..e53a64ac7 --- /dev/null +++ b/ui/constants/video.js @@ -0,0 +1,5 @@ +// Quality Options +export const AUTO = 'auto'; +export const ORIGINAL = 'original'; + +export const VIDEO_QUALITY_OPTIONS = [AUTO, ORIGINAL, 144, 240, 360, 480, 720, 1080]; diff --git a/ui/redux/actions/settings.js b/ui/redux/actions/settings.js index 24e6625d0..d3415c469 100644 --- a/ui/redux/actions/settings.js +++ b/ui/redux/actions/settings.js @@ -493,3 +493,6 @@ export function toggleAutoplayNext() { ); }; } + +export const doSetDefaultVideoQuality = (value) => (dispatch) => + dispatch(doSetClientSetting(SETTINGS.DEFAULT_VIDEO_QUALITY, value, true)); diff --git a/ui/redux/reducers/settings.js b/ui/redux/reducers/settings.js index 6163cbab6..6e9b1e787 100644 --- a/ui/redux/reducers/settings.js +++ b/ui/redux/reducers/settings.js @@ -76,6 +76,7 @@ const defaultState = { [SETTINGS.AUTO_DOWNLOAD]: true, [SETTINGS.HIDE_REPOSTS]: false, [SETTINGS.HIDE_SCHEDULED_LIVESTREAMS]: false, + [SETTINGS.DEFAULT_VIDEO_QUALITY]: null, // OS [SETTINGS.AUTO_LAUNCH]: true,