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,