1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-27 23:06:29 -04:00

New: Quality limits are part of Quality Profile

Closes #613
This commit is contained in:
Mark McDowall
2024-04-27 14:49:15 -07:00
parent 9ce473d9bb
commit 64c1ef85c4
45 changed files with 1196 additions and 657 deletions
@@ -21,7 +21,7 @@ class EditQualityProfileModal extends Component {
// Listeners
onContentHeightChange = (height) => {
if (this.state.height === 'auto' || height > this.state.height) {
if (this.state.height === 'auto' || height !== 0) {
this.setState({ height });
}
};
@@ -44,6 +44,9 @@ class EditQualityProfileModalContent extends Component {
this.state = {
headerHeight: 0,
bodyHeight: 0,
defaultBodyHeight: 0,
editGroupsBodyHeight: 0,
editSizesBodyHeight: 0,
footerHeight: 0
};
}
@@ -51,17 +54,18 @@ class EditQualityProfileModalContent extends Component {
componentDidUpdate(prevProps, prevState) {
const {
headerHeight,
bodyHeight,
footerHeight
} = this.state;
const bodyHeight = this.state[`${this.props.mode}BodyHeight`];
if (
headerHeight > 0 &&
bodyHeight > 0 &&
footerHeight > 0 &&
(
headerHeight !== prevState.headerHeight ||
bodyHeight !== prevState.bodyHeight ||
bodyHeight !== prevState[`${prevProps.mode}BodyHeight`] ||
footerHeight !== prevState.footerHeight
)
) {
@@ -77,15 +81,16 @@ class EditQualityProfileModalContent extends Component {
// Listeners
onHeaderMeasure = ({ height }) => {
if (height > this.state.headerHeight) {
if (height !== this.state.headerHeight) {
this.setState({ headerHeight: height });
}
};
onBodyMeasure = ({ height }) => {
const heightKey = `${this.props.mode}BodyHeight`;
if (height > this.state.bodyHeight) {
this.setState({ bodyHeight: height });
if (height !== this.state[heightKey]) {
this.setState({ [heightKey]: height });
}
};
@@ -100,7 +105,7 @@ class EditQualityProfileModalContent extends Component {
render() {
const {
editGroups,
mode,
isFetching,
error,
isSaving,
@@ -271,7 +276,7 @@ class EditQualityProfileModalContent extends Component {
<div className={styles.formGroupWrapper}>
<QualityProfileItems
editGroups={editGroups}
mode={mode}
qualityProfileItems={items.value}
errors={items.errors}
warnings={items.warnings}
@@ -338,7 +343,7 @@ class EditQualityProfileModalContent extends Component {
}
EditQualityProfileModalContent.propTypes = {
editGroups: PropTypes.bool.isRequired,
mode: PropTypes.string.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
@@ -126,7 +126,7 @@ class EditQualityProfileModalContentConnector extends Component {
dragQualityIndex: null,
dropQualityIndex: null,
dropPosition: null,
editGroups: false
mode: 'default' // default, editGroups, editSizes
};
}
@@ -256,6 +256,49 @@ class EditQualityProfileModalContentConnector extends Component {
});
};
onSizeChange = ({ id, minSize, maxSize, preferredSize }) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
let quality = null;
// eslint-disable-next-line guard-for-in
for (const index in items) {
const item = items[index];
if (item.quality?.id === id) {
quality = item;
break;
}
// eslint-disable-next-line guard-for-in
for (const i in item.items) {
const nestedItem = items[i];
if (nestedItem.quality?.id === id) {
quality = nestedItem;
break;
}
}
if (quality) {
break;
}
}
if (!quality) {
return;
}
quality.minSize = minSize;
quality.maxSize = maxSize;
quality.preferredSize = preferredSize;
this.props.setQualityProfileValue({
name: 'items',
value: items
});
};
onCreateGroupPress = (id) => {
const qualityProfile = _.cloneDeep(this.props.item);
const items = qualityProfile.items.value;
@@ -439,8 +482,8 @@ class EditQualityProfileModalContentConnector extends Component {
});
};
onToggleEditGroupsMode = () => {
this.setState({ editGroups: !this.state.editGroups });
onChangeMode = (mode) => {
this.setState({ mode });
};
//
@@ -466,7 +509,8 @@ class EditQualityProfileModalContentConnector extends Component {
onQualityProfileItemDragMove={this.onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={this.onQualityProfileItemDragEnd}
onQualityProfileFormatItemScoreChange={this.onQualityProfileFormatItemScoreChange}
onToggleEditGroupsMode={this.onToggleEditGroupsMode}
onChangeMode={this.onChangeMode}
onSizeChange={this.onSizeChange}
/>
);
}
@@ -9,6 +9,11 @@
&.isInGroup {
border-style: dashed;
}
&.editSizes {
flex-direction: column;
padding: 10px;
}
}
.checkInputContainer {
@@ -32,6 +37,10 @@
font-weight: normal;
line-height: $qualityProfileItemHeight;
cursor: pointer;
&.editSizes {
cursor: default;
}
}
.qualityName {
@@ -6,6 +6,7 @@ interface CssExports {
'createGroupButton': string;
'dragHandle': string;
'dragIcon': string;
'editSizes': string;
'isDragging': string;
'isInGroup': string;
'isPreview': string;
@@ -6,6 +6,7 @@ import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QualityProfileItemSize from './QualityProfileItemSize';
import styles from './QualityProfileItem.css';
class QualityProfileItem extends Component {
@@ -36,20 +37,26 @@ class QualityProfileItem extends Component {
render() {
const {
editGroups,
mode,
isPreview,
qualityId,
groupId,
name,
allowed,
minSize,
maxSize,
preferredSize,
isDragging,
isOverCurrent,
connectDragSource
connectDragSource,
onSizeChange
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItem,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging,
isPreview && styles.isPreview,
isOverCurrent && styles.isOverCurrent,
@@ -57,10 +64,13 @@ class QualityProfileItem extends Component {
)}
>
<label
className={styles.qualityNameContainer}
className={classNames(
styles.qualityNameContainer,
mode === 'editSizes' && styles.editSizes
)}
>
{
editGroups && !groupId && !isPreview &&
mode === 'editGroups' && !groupId && !isPreview &&
<IconButton
className={styles.createGroupButton}
name={icons.GROUP}
@@ -70,7 +80,7 @@ class QualityProfileItem extends Component {
}
{
!editGroups &&
mode === 'default' &&
<CheckInput
className={styles.checkInput}
containerClassName={styles.checkInputContainer}
@@ -83,7 +93,7 @@ class QualityProfileItem extends Component {
<div className={classNames(
styles.qualityName,
groupId && styles.isInGroup,
groupId && mode !== 'editSizes' && styles.isInGroup,
!allowed && styles.notAllowed
)}
>
@@ -92,15 +102,30 @@ class QualityProfileItem extends Component {
</label>
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title={translate('CreateGroup')}
name={icons.REORDER}
mode === 'editSizes' && qualityId != null ?
<div>
<QualityProfileItemSize
id={qualityId}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
onSizeChange={onSizeChange}
/>
</div>
)
</div> :
null
}
{
mode === 'editSizes' ? null :
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
title={translate('CreateGroup')}
name={icons.REORDER}
/>
</div>
)
}
</div>
);
@@ -108,21 +133,26 @@ class QualityProfileItem extends Component {
}
QualityProfileItem.propTypes = {
editGroups: PropTypes.bool,
mode: PropTypes.string.isRequired,
isPreview: PropTypes.bool,
groupId: PropTypes.number,
qualityId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
isDragging: PropTypes.bool.isRequired,
isOverCurrent: PropTypes.bool.isRequired,
isInGroup: PropTypes.bool,
connectDragSource: PropTypes.func,
onCreateGroupPress: PropTypes.func,
onQualityProfileItemAllowedChange: PropTypes.func
onQualityProfileItemAllowedChange: PropTypes.func,
onSizeChange: PropTypes.func
};
QualityProfileItem.defaultProps = {
mode: 'default',
isPreview: false,
isOverCurrent: false,
// The drag preview will not connect the drag handle.
@@ -11,7 +11,7 @@ import styles from './QualityProfileItemDragSource.css';
const qualityProfileItemDragSource = {
beginDrag(props) {
const {
editGroups,
mode,
qualityIndex,
groupId,
qualityId,
@@ -20,7 +20,7 @@ const qualityProfileItemDragSource = {
} = props;
return {
editGroups,
mode,
qualityIndex,
groupId,
qualityId,
@@ -110,12 +110,15 @@ class QualityProfileItemDragSource extends Component {
render() {
const {
editGroups,
mode,
groupId,
qualityId,
name,
allowed,
items,
minSize,
maxSize,
preferredSize,
qualityIndex,
isDragging,
isDraggingUp,
@@ -129,7 +132,8 @@ class QualityProfileItemDragSource extends Component {
onItemGroupAllowedChange,
onItemGroupNameChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
onQualityProfileItemDragEnd,
onSizeChange
} = this.props;
const isBefore = !isDragging && isDraggingUp && isOverCurrent;
@@ -156,7 +160,7 @@ class QualityProfileItemDragSource extends Component {
{
!!groupId && qualityId == null &&
<QualityProfileItemGroup
editGroups={editGroups}
mode={mode}
groupId={groupId}
name={name}
allowed={allowed}
@@ -172,23 +176,28 @@ class QualityProfileItemDragSource extends Component {
onItemGroupNameChange={onItemGroupNameChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
onSizeChange={onSizeChange}
/>
}
{
qualityId != null &&
<QualityProfileItem
editGroups={editGroups}
mode={mode}
groupId={groupId}
qualityId={qualityId}
name={name}
allowed={allowed}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={qualityIndex}
isDragging={isDragging}
isOverCurrent={isOverCurrent}
connectDragSource={connectDragSource}
onCreateGroupPress={onCreateGroupPress}
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onSizeChange={onSizeChange}
/>
}
@@ -207,12 +216,15 @@ class QualityProfileItemDragSource extends Component {
}
QualityProfileItemDragSource.propTypes = {
editGroups: PropTypes.bool.isRequired,
mode: PropTypes.string.isRequired,
groupId: PropTypes.number,
qualityId: PropTypes.number,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
qualityIndex: PropTypes.string.isRequired,
isDragging: PropTypes.bool,
isDraggingUp: PropTypes.bool,
@@ -227,7 +239,8 @@ QualityProfileItemDragSource.propTypes = {
onItemGroupAllowedChange: PropTypes.func,
onItemGroupNameChange: PropTypes.func,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
onQualityProfileItemDragEnd: PropTypes.func.isRequired,
onSizeChange: PropTypes.func.isRequired
};
export default DropTarget(
@@ -7,6 +7,10 @@
&.editGroups {
background: var(--inputBackgroundColor);
}
&.editSizes {
padding: 10px;
}
}
.qualityProfileItemGroupInfo {
@@ -70,6 +74,10 @@
cursor: pointer;
}
.editSizesQualityNameLabel {
composes: qualityNameContainer;
}
.deleteGroupButton {
composes: buton from '~Components/Link/IconButton.css';
@@ -7,6 +7,8 @@ interface CssExports {
'dragHandle': string;
'dragIcon': string;
'editGroups': string;
'editSizes': string;
'editSizesQualityNameLabel': string;
'groupQualities': string;
'isDragging': string;
'items': string;
@@ -48,7 +48,7 @@ class QualityProfileItemGroup extends Component {
render() {
const {
editGroups,
mode,
groupId,
name,
allowed,
@@ -60,20 +60,22 @@ class QualityProfileItemGroup extends Component {
connectDragSource,
onQualityProfileItemAllowedChange,
onQualityProfileItemDragMove,
onQualityProfileItemDragEnd
onQualityProfileItemDragEnd,
onSizeChange
} = this.props;
return (
<div
className={classNames(
styles.qualityProfileItemGroup,
editGroups && styles.editGroups,
mode === 'editGroups' && styles.editGroups,
mode === 'editSizes' && styles.editSizes,
isDragging && styles.isDragging
)}
>
<div className={styles.qualityProfileItemGroupInfo}>
{
editGroups &&
mode === 'editGroups' &&
<div className={styles.qualityNameContainer}>
<IconButton
className={styles.deleteGroupButton}
@@ -92,7 +94,7 @@ class QualityProfileItemGroup extends Component {
}
{
!editGroups &&
mode === 'default' &&
<label
className={styles.qualityNameLabel}
>
@@ -129,31 +131,53 @@ class QualityProfileItemGroup extends Component {
}
{
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title={translate('Reorder')}
/>
</div>
)
mode === 'editSizes' &&
<label
className={styles.editSizesQualityNameLabel}
>
<div className={styles.nameContainer}>
<div className={classNames(
styles.name,
!allowed && styles.notAllowed
)}
>
{name}
</div>
</div>
</label>
}
{
mode === 'editSizes' ? null :
connectDragSource(
<div className={styles.dragHandle}>
<Icon
className={styles.dragIcon}
name={icons.REORDER}
title={translate('Reorder')}
/>
</div>
)
}
</div>
{
editGroups &&
<div className={styles.items}>
mode === 'default' ?
null :
<div className={mode === 'editGroups' ? styles.items : undefined}>
{
items.map(({ quality }, index) => {
return (
<QualityProfileItemDragSource
key={quality.id}
editGroups={editGroups}
mode={mode}
groupId={groupId}
qualityId={quality.id}
name={quality.name}
allowed={allowed}
minSize={quality.minSize}
maxSize={quality.maxSize}
preferredSize={quality.preferredSize}
items={items}
qualityIndex={`${qualityIndex}.${index + 1}`}
isDragging={isDragging}
@@ -163,6 +187,7 @@ class QualityProfileItemGroup extends Component {
onQualityProfileItemAllowedChange={onQualityProfileItemAllowedChange}
onQualityProfileItemDragMove={onQualityProfileItemDragMove}
onQualityProfileItemDragEnd={onQualityProfileItemDragEnd}
onSizeChange={onSizeChange}
/>
);
}).reverse()
@@ -175,7 +200,7 @@ class QualityProfileItemGroup extends Component {
}
QualityProfileItemGroup.propTypes = {
editGroups: PropTypes.bool,
mode: PropTypes.string.isRequired,
groupId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
allowed: PropTypes.bool.isRequired,
@@ -190,10 +215,12 @@ QualityProfileItemGroup.propTypes = {
onItemGroupNameChange: PropTypes.func.isRequired,
onDeleteGroupPress: PropTypes.func.isRequired,
onQualityProfileItemDragMove: PropTypes.func.isRequired,
onQualityProfileItemDragEnd: PropTypes.func.isRequired
onQualityProfileItemDragEnd: PropTypes.func.isRequired,
onSizeChange: PropTypes.func
};
QualityProfileItemGroup.defaultProps = {
mode: 'default',
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
@@ -0,0 +1,60 @@
.sizeLimit {
flex: 0 1 500px;
}
.slider {
width: 100%;
height: 20px;
}
.track {
top: 9px;
margin: 0 5px;
height: 3px;
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(3n + 1) {
background-color: #ddd;
}
}
.thumb {
top: 1px;
z-index: 0 !important;
width: 18px;
height: 18px;
border: 3px solid var(--sliderAccentColor);
border-radius: 50%;
background-color: var(--white);
text-align: center;
cursor: pointer;
}
.sizes {
display: flex;
justify-content: space-between;
}
.megabytesPerMinuteContainer {
display: flex;
justify-content: space-between;
flex: 0 0 400px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--borderColor);
}
.megabytesPerMinute {
display: flex;
flex-direction: column;
}
.sizeInput {
composes: input from '~Components/Form/TextInput.css';
display: inline-block;
flex-grow: 1;
margin-bottom: 2px;
padding: 6px;
}
@@ -0,0 +1,14 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'megabytesPerMinute': string;
'megabytesPerMinuteContainer': string;
'sizeInput': string;
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'track': string;
}
export const cssExports: CssExports;
export default cssExports;
@@ -0,0 +1,295 @@
import React, { HTMLProps, useCallback, useState } from 'react';
import ReactSlider from 'react-slider';
import NumberInput from 'Components/Form/NumberInput';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import QualityDefinitionLimits from 'Settings/Quality/Definition/QualityDefinitionLimits';
import { InputChanged } from 'typings/inputs';
import formatBytes from 'Utilities/Number/formatBytes';
import roundNumber from 'Utilities/Number/roundNumber';
import translate from 'Utilities/String/translate';
import styles from './QualityProfileItemSize.css';
const MIN = 0;
const MAX = 400;
const STEP_SIZE = 0.1;
const MIN_DISTANCE = 3;
const SLIDER_MAX = roundNumber(Math.pow(MAX, 1 / 1.1));
interface SizeProps {
minSize: number | null;
preferredSize: number | null;
maxSize: number | null;
}
export interface OnSizeChangeArguments extends SizeProps {
id: number;
}
export interface QualityProfileItemSizeProps extends OnSizeChangeArguments {
onSizeChange: (props: OnSizeChangeArguments) => void;
}
function trackRenderer(props: HTMLProps<HTMLDivElement>) {
return <div {...props} className={styles.track} />;
}
function thumbRenderer(props: HTMLProps<HTMLDivElement>) {
return <div {...props} className={styles.thumb} />;
}
function getSliderValue(value: number | null, defaultValue: number): number {
const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
return roundNumber(sliderValue);
}
export default function QualityProfileItemSize(
props: QualityProfileItemSizeProps
) {
const { id, minSize, maxSize, preferredSize, onSizeChange } = props;
const [sizes, setSizes] = useState<SizeProps>({
minSize: getSliderValue(minSize, MIN),
preferredSize: getSliderValue(preferredSize, SLIDER_MAX - MIN_DISTANCE),
maxSize: getSliderValue(maxSize, SLIDER_MAX),
});
const handleSliderChange = useCallback(
([sliderMinSize, sliderPreferredSize, sliderMaxSize]: [
number,
number,
number
]) => {
// console.log('Sizes:', sliderMinSize, sliderPreferredSize, sliderMaxSize);
console.log(
'Min Sizes: ',
sliderMinSize,
roundNumber(Math.pow(sliderMinSize, 1.1))
);
setSizes({
minSize: sliderMinSize,
preferredSize: sliderPreferredSize,
maxSize: sliderMaxSize,
});
onSizeChange({
id,
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize:
sliderPreferredSize === MAX - MIN_DISTANCE
? null
: roundNumber(Math.pow(sliderPreferredSize, 1.1)),
maxSize:
sliderMaxSize === MAX
? null
: roundNumber(Math.pow(sliderMaxSize, 1.1)),
});
},
[id, setSizes, onSizeChange]
);
const handleMinSizeChange = useCallback(
({ value }: InputChanged<number>) => {
setSizes({
minSize: value,
preferredSize: sizes.preferredSize,
maxSize: sizes.maxSize,
});
onSizeChange({
id,
minSize: value,
preferredSize: sizes.preferredSize,
maxSize: sizes.maxSize,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handlePreferredSizeChange = useCallback(
({ value }: InputChanged<number>) => {
setSizes({
minSize: sizes.minSize,
preferredSize: value,
maxSize: sizes.maxSize,
});
onSizeChange({
id,
minSize: sizes.minSize,
preferredSize: value,
maxSize: sizes.maxSize,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handleMaxSizeChange = useCallback(
({ value }: InputChanged<number>) => {
setSizes({
minSize: sizes.minSize,
preferredSize: sizes.preferredSize,
maxSize: value,
});
onSizeChange({
id,
minSize: sizes.minSize,
preferredSize: sizes.preferredSize,
maxSize: value,
});
},
[id, sizes, setSizes, onSizeChange]
);
const handleAfterSliderChange = useCallback(() => {
setSizes({
minSize: getSliderValue(minSize, MIN),
maxSize: getSliderValue(maxSize, MAX),
preferredSize: getSliderValue(preferredSize, MAX - MIN_DISTANCE),
});
}, [minSize, maxSize, preferredSize, setSizes]);
const minBytes = (sizes.minSize || 0) * 1024 * 1024;
const minSixty = `${formatBytes(minBytes * 60)}/${translate(
'HourShorthand'
)}`;
const preferredBytes = (sizes.preferredSize || 0) * 1024 * 1024;
const preferredSixty = preferredBytes
? `${formatBytes(preferredBytes * 60)}/${translate('HourShorthand')}`
: translate('Unlimited');
const maxBytes = maxSize && maxSize * 1024 * 1024;
const maxSixty = maxBytes
? `${formatBytes(maxBytes * 60)}/${translate('HourShorthand')}`
: translate('Unlimited');
return (
<div className={styles.sizeLimit}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore React version mismatch */}
<ReactSlider
className={styles.slider}
min={MIN}
max={SLIDER_MAX}
step={STEP_SIZE}
minDistance={3}
value={[sizes.minSize, sizes.preferredSize, sizes.maxSize]}
withTracks={true}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore allowCross is still available in the version currently used
allowCross={false}
snapDragDisabled={true}
renderThumb={thumbRenderer}
renderTrack={trackRenderer}
onChange={handleSliderChange}
onAfterChange={handleAfterSliderChange}
/>
<div className={styles.sizes}>
<div>
<Popover
anchor={<Label kind={kinds.INFO}>{minSixty}</Label>}
title={translate('MinimumLimits')}
body={
<QualityDefinitionLimits
bytes={minBytes}
message={translate('NoMinimumForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={<Label kind={kinds.SUCCESS}>{preferredSixty}</Label>}
title={translate('PreferredSize')}
body={
<QualityDefinitionLimits
bytes={preferredBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={<Label kind={kinds.WARNING}>{maxSixty}</Label>}
title={translate('MaximumLimits')}
body={
<QualityDefinitionLimits
bytes={maxBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
</div>
<div className={styles.megabytesPerMinuteContainer}>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={preferredSize ? preferredSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
// @ts-expect-error - Typngs are too loose
onChange={handleMinSizeChange}
/>
<Label kind={kinds.INFO}>
{translate('Minimum')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={preferredSize || MAX - 5}
min={MIN}
max={maxSize ? maxSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
// @ts-expect-error - Typngs are too loose
onChange={handlePreferredSizeChange}
/>
<Label kind={kinds.SUCCESS}>
{translate('Preferred')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
<div className={styles.megabytesPerMinute}>
<NumberInput
className={styles.sizeInput}
name={`${id}.max`}
value={maxSize || MAX}
min={(preferredSize || 0) + STEP_SIZE}
max={MAX}
step={0.1}
isFloat={true}
// @ts-expect-error - Typngs are too loose
onChange={handleMaxSizeChange}
/>
<Label kind={kinds.WARNING}>
{translate('Maximum')} MiB/
{translate('MinuteShorthand')}
</Label>
</div>
</div>
</div>
);
}
@@ -4,7 +4,14 @@
margin-top: 10px;
}
.editGroupsButtonIcon {
.editSizesButton {
composes: button from '~Components/Link/Button.css';
margin-top: 10px;
margin-left: 10px;
}
.editButtonIcon {
margin-right: 8px;
}
@@ -1,8 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'editButtonIcon': string;
'editGroupsButton': string;
'editGroupsButtonIcon': string;
'editSizesButton': string;
'qualities': string;
}
export const cssExports: CssExports;
@@ -21,8 +21,9 @@ class QualityProfileItems extends Component {
super(props, context);
this.state = {
qualitiesHeight: 0,
qualitiesHeightEditGroups: 0
defaultHeight: 0,
editGroupsHeight: 0,
editSizesHeight: 0
};
}
@@ -30,17 +31,23 @@ class QualityProfileItems extends Component {
// Listeners
onMeasure = ({ height }) => {
if (this.props.editGroups) {
this.setState({
qualitiesHeightEditGroups: height
});
} else {
this.setState({ qualitiesHeight: height });
}
const heightKey = `${this.props.mode}Height`;
this.setState({
[heightKey]: height
});
};
onToggleEditGroupsMode = () => {
this.props.onToggleEditGroupsMode();
onEditGroupsPress = () => {
this.props.onChangeMode('editGroups');
};
onEditSizesPress = () => {
this.props.onChangeMode('editSizes');
};
onDefaultModePress = () => {
this.props.onChangeMode('default');
};
//
@@ -48,7 +55,7 @@ class QualityProfileItems extends Component {
render() {
const {
editGroups,
mode,
dropQualityIndex,
dropPosition,
qualityProfileItems,
@@ -57,15 +64,10 @@ class QualityProfileItems extends Component {
...otherProps
} = this.props;
const {
qualitiesHeight,
qualitiesHeightEditGroups
} = this.state;
const isDragging = dropQualityIndex !== null;
const isDraggingUp = isDragging && dropPosition === 'above';
const isDraggingDown = isDragging && dropPosition === 'below';
const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight;
const height = this.state[`${mode}Height`];
return (
<FormGroup size={sizes.EXTRA_SMALL}>
@@ -107,16 +109,33 @@ class QualityProfileItems extends Component {
<Button
className={styles.editGroupsButton}
kind={kinds.PRIMARY}
onPress={this.onToggleEditGroupsMode}
onPress={mode === 'editGroups' ? this.onDefaultModePress : this.onEditGroupsPress}
>
<div>
<Icon
className={styles.editGroupsButtonIcon}
name={editGroups ? icons.REORDER : icons.GROUP}
className={styles.editButtonIcon}
name={mode === 'editGroups' ? icons.REORDER : icons.GROUP}
/>
{
editGroups ? translate('DoneEditingGroups') : translate('EditGroups')
mode === 'editGroups' ? translate('DoneEditingGroups') : translate('EditGroups')
}
</div>
</Button>
<Button
className={styles.editSizesButton}
kind={kinds.PRIMARY}
onPress={mode === 'editSizes' ? this.onDefaultModePress : this.onEditSizesPress}
>
<div>
<Icon
className={styles.editButtonIcon}
name={mode === 'editSizes' ? icons.REORDER : icons.FILE}
/>
{
mode === 'editSizes' ? translate('DoneEditingSizes') : translate('EditSizes')
}
</div>
</Button>
@@ -128,21 +147,24 @@ class QualityProfileItems extends Component {
>
<div
className={styles.qualities}
style={{ minHeight: `${minHeight}px` }}
style={{ height: `${height}px` }}
>
{
qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => {
qualityProfileItems.map(({ id, name, allowed, quality, items, minSize, maxSize, preferredSize }, index) => {
const identifier = quality ? quality.id : id;
return (
<QualityProfileItemDragSource
key={identifier}
editGroups={editGroups}
mode={mode}
groupId={id}
qualityId={quality && quality.id}
name={quality ? quality.name : name}
allowed={allowed}
items={items}
minSize={minSize}
maxSize={maxSize}
preferredSize={preferredSize}
qualityIndex={`${index + 1}`}
isInGroup={false}
isDragging={isDragging}
@@ -164,14 +186,14 @@ class QualityProfileItems extends Component {
}
QualityProfileItems.propTypes = {
editGroups: PropTypes.bool.isRequired,
mode: PropTypes.string.isRequired,
dragQualityIndex: PropTypes.string,
dropQualityIndex: PropTypes.string,
dropPosition: PropTypes.string,
qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object),
onToggleEditGroupsMode: PropTypes.func.isRequired
onChangeMode: PropTypes.func.isRequired
};
QualityProfileItems.defaultProps = {
@@ -14,60 +14,6 @@
line-height: 40px;
}
.sizeLimit {
flex: 0 1 500px;
padding-right: 30px;
}
.slider {
width: 100%;
height: 20px;
}
.track {
top: 9px;
margin: 0 5px;
height: 3px;
background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000;
&:nth-child(3n+1) {
background-color: #ddd;
}
}
.thumb {
top: 1px;
z-index: 0 !important;
width: 18px;
height: 18px;
border: 3px solid var(--sliderAccentColor);
border-radius: 50%;
background-color: var(--white);
text-align: center;
cursor: pointer;
}
.sizes {
display: flex;
justify-content: space-between;
}
.megabytesPerMinute {
display: flex;
justify-content: space-between;
flex: 0 0 400px;
}
.sizeInput {
composes: input from '~Components/Form/TextInput.css';
display: inline-block;
margin-left: 5px;
padding: 6px;
width: 75px;
}
@media only screen and (max-width: $breakpointSmall) {
.qualityDefinition {
flex-wrap: wrap;
@@ -86,8 +32,4 @@
font-weight: bold;
line-height: inherit;
}
.sizeLimit {
margin-top: 10px;
}
}
@@ -1,16 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'megabytesPerMinute': string;
'quality': string;
'qualityDefinition': string;
'sizeInput': string;
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'title': string;
'track': string;
}
export const cssExports: CssExports;
export default cssExports;
@@ -1,346 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactSlider from 'react-slider';
import NumberInput from 'Components/Form/NumberInput';
import TextInput from 'Components/Form/TextInput';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import formatBytes from 'Utilities/Number/formatBytes';
import roundNumber from 'Utilities/Number/roundNumber';
import translate from 'Utilities/String/translate';
import QualityDefinitionLimits from './QualityDefinitionLimits';
import styles from './QualityDefinition.css';
const MIN = 0;
const MAX = 1000;
const MIN_DISTANCE = 1;
const slider = {
min: MIN,
max: roundNumber(Math.pow(MAX, 1 / 1.1)),
step: 0.1
};
function getValue(inputValue) {
if (inputValue < MIN) {
return MIN;
}
if (inputValue > MAX) {
return MAX;
}
return roundNumber(inputValue);
}
function getSliderValue(value, defaultValue) {
const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue;
return roundNumber(sliderValue);
}
class QualityDefinition extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._forceUpdateTimeout = null;
this.state = {
sliderMinSize: getSliderValue(props.minSize, slider.min),
sliderMaxSize: getSliderValue(props.maxSize, slider.max),
sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3))
};
}
componentDidMount() {
// A hack to deal with a bug in the slider component until a fix for it
// lands and an updated version is available.
// See: https://github.com/mpowaga/react-slider/issues/115
this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1);
}
componentWillUnmount() {
if (this._forceUpdateTimeout) {
clearTimeout(this._forceUpdateTimeout);
}
}
//
// Control
trackRenderer(props, state) {
return (
<div
{...props}
className={styles.track}
/>
);
}
thumbRenderer(props, state) {
return (
<div
{...props}
className={styles.thumb}
/>
);
}
//
// Listeners
onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => {
this.setState({
sliderMinSize,
sliderMaxSize,
sliderPreferredSize
});
this.props.onSizeChange({
minSize: roundNumber(Math.pow(sliderMinSize, 1.1)),
preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)),
maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1))
});
};
onAfterSliderChange = () => {
const {
minSize,
maxSize,
preferredSize
} = this.props;
this.setState({
sliderMiSize: getSliderValue(minSize, slider.min),
sliderMaxSize: getSliderValue(maxSize, slider.max),
sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix
});
};
onMinSizeChange = ({ value }) => {
const minSize = getValue(value);
this.setState({
sliderMinSize: getSliderValue(minSize, slider.min)
});
this.props.onSizeChange({
minSize,
maxSize: this.props.maxSize,
preferredSize: this.props.preferredSize
});
};
onPreferredSizeChange = ({ value }) => {
const preferredSize = value === (MAX - 3) ? null : getValue(value);
this.setState({
sliderPreferredSize: getSliderValue(preferredSize, slider.preferred)
});
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize: this.props.maxSize,
preferredSize
});
};
onMaxSizeChange = ({ value }) => {
const maxSize = value === MAX ? null : getValue(value);
this.setState({
sliderMaxSize: getSliderValue(maxSize, slider.max)
});
this.props.onSizeChange({
minSize: this.props.minSize,
maxSize,
preferredSize: this.props.preferredSize
});
};
//
// Render
render() {
const {
id,
quality,
title,
minSize,
maxSize,
preferredSize,
advancedSettings,
onTitleChange
} = this.props;
const {
sliderMinSize,
sliderMaxSize,
sliderPreferredSize
} = this.state;
const minBytes = minSize * 1024 * 1024;
const minSixty = `${formatBytes(minBytes * 60)}/${translate('HourShorthand')}`;
const preferredBytes = preferredSize * 1024 * 1024;
const preferredSixty = preferredBytes ? `${formatBytes(preferredBytes * 60)}/${translate('HourShorthand')}` : translate('Unlimited');
const maxBytes = maxSize && maxSize * 1024 * 1024;
const maxSixty = maxBytes ? `${formatBytes(maxBytes * 60)}/${translate('HourShorthand')}` : translate('Unlimited');
return (
<div className={styles.qualityDefinition}>
<div className={styles.quality}>
{quality.name}
</div>
<div className={styles.title}>
<TextInput
name={`${id}.${title}`}
value={title}
onChange={onTitleChange}
/>
</div>
<div className={styles.sizeLimit}>
<ReactSlider
className={styles.slider}
min={slider.min}
max={slider.max}
step={slider.step}
minDistance={3}
value={[sliderMinSize, sliderPreferredSize, sliderMaxSize]}
withTracks={true}
allowCross={false}
snapDragDisabled={true}
renderThumb={this.thumbRenderer}
renderTrack={this.trackRenderer}
onChange={this.onSliderChange}
onAfterChange={this.onAfterSliderChange}
/>
<div className={styles.sizes}>
<div>
<Popover
anchor={
<Label kind={kinds.INFO}>{minSixty}</Label>
}
title={translate('MinimumLimits')}
body={
<QualityDefinitionLimits
bytes={minBytes}
message={translate('NoMinimumForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={
<Label kind={kinds.SUCCESS}>{preferredSixty}</Label>
}
title={translate('PreferredSize')}
body={
<QualityDefinitionLimits
bytes={preferredBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
<div>
<Popover
anchor={
<Label kind={kinds.WARNING}>{maxSixty}</Label>
}
title={translate('MaximumLimits')}
body={
<QualityDefinitionLimits
bytes={maxBytes}
message={translate('NoLimitForAnyRuntime')}
/>
}
position={tooltipPositions.BOTTOM}
/>
</div>
</div>
</div>
{
advancedSettings &&
<div className={styles.megabytesPerMinute}>
<div>
{translate('Min')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={minSize || MIN}
min={MIN}
max={preferredSize ? preferredSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onMinSizeChange}
/>
</div>
<div>
{translate('Preferred')}
<NumberInput
className={styles.sizeInput}
name={`${id}.min`}
value={preferredSize || MAX - 5}
min={MIN}
max={maxSize ? maxSize - 5 : MAX - 5}
step={0.1}
isFloat={true}
onChange={this.onPreferredSizeChange}
/>
</div>
<div>
{translate('Max')}
<NumberInput
className={styles.sizeInput}
name={`${id}.max`}
value={maxSize || MAX}
min={minSize + MIN_DISTANCE}
max={MAX}
step={0.1}
isFloat={true}
onChange={this.onMaxSizeChange}
/>
</div>
</div>
}
</div>
);
}
}
QualityDefinition.propTypes = {
id: PropTypes.number.isRequired,
quality: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
advancedSettings: PropTypes.bool.isRequired,
onTitleChange: PropTypes.func.isRequired,
onSizeChange: PropTypes.func.isRequired
};
export default QualityDefinition;
@@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import TextInput from 'Components/Form/TextInput';
import Quality from 'Quality/Quality';
import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
import styles from './QualityDefinition.css';
interface QualityDefinitionProps {
id: number;
quality: Quality;
title: string;
}
function QualityDefinition(props: QualityDefinitionProps) {
const { id, quality, title } = props;
const dispatch = useDispatch();
const handleTitleChange = useCallback(
({ value }: { value: string }) => {
dispatch(
setQualityDefinitionValue({
id,
name: 'title',
value,
})
);
},
[id, dispatch]
);
return (
<div className={styles.qualityDefinition}>
<div className={styles.quality}>{quality.name}</div>
<div className={styles.title}>
<TextInput
name={`${id}.${title}`}
value={title}
onChange={handleTitleChange}
/>
</div>
</div>
);
}
export default QualityDefinition;
@@ -1,71 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
import QualityDefinition from './QualityDefinition';
const mapDispatchToProps = {
setQualityDefinitionValue,
clearPendingChanges
};
class QualityDefinitionConnector extends Component {
componentWillUnmount() {
this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' });
}
//
// Listeners
onTitleChange = ({ value }) => {
this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
};
onSizeChange = ({ minSize, maxSize, preferredSize }) => {
const {
id,
minSize: currentMinSize,
maxSize: currentMaxSize,
preferredSize: currentPreferredSize
} = this.props;
if (minSize !== currentMinSize) {
this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
}
if (maxSize !== currentMaxSize) {
this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
}
if (preferredSize !== currentPreferredSize) {
this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize });
}
};
//
// Render
render() {
return (
<QualityDefinition
{...this.props}
onTitleChange={this.onTitleChange}
onSizeChange={this.onSizeChange}
/>
);
}
}
QualityDefinitionConnector.propTypes = {
id: PropTypes.number.isRequired,
advancedSettings: PropTypes.bool.isRequired,
minSize: PropTypes.number,
maxSize: PropTypes.number,
preferredSize: PropTypes.number,
setQualityDefinitionValue: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(QualityDefinitionConnector);
@@ -8,24 +8,11 @@
flex: 0 1 250px;
}
.sizeLimit {
flex: 0 1 500px;
}
.notice {
composes: alert from '~Components/Alert.css';
.megabytesPerMinute {
flex: 0 0 250px;
}
.sizeLimitHelpTextContainer {
display: flex;
justify-content: flex-end;
margin-top: 20px;
max-width: 1000px;
}
.sizeLimitHelpText {
max-width: 500px;
color: var(--helpTextColor);
margin: 0;
margin-bottom: 20px;
}
@media only screen and (max-width: $breakpointSmall) {
@@ -3,11 +3,8 @@
interface CssExports {
'definitions': string;
'header': string;
'megabytesPerMinute': string;
'notice': string;
'quality': string;
'sizeLimit': string;
'sizeLimitHelpText': string;
'sizeLimitHelpTextContainer': string;
'title': string;
}
export const cssExports: CssExports;
+105
View File
@@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import QualityDefinitions from './Definition/QualityDefinitions';
import ResetQualityDefinitionsModal from './Reset/ResetQualityDefinitionsModal';
class Quality extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._saveCallback = null;
this.state = {
isSaving: false,
hasPendingChanges: false,
isConfirmQualityDefinitionResetModalOpen: false
};
}
//
// Listeners
onChildMounted = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
onResetQualityDefinitionsPress = () => {
this.setState({ isConfirmQualityDefinitionResetModalOpen: true });
};
onCloseResetQualityDefinitionsModal = () => {
this.setState({ isConfirmQualityDefinitionResetModalOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
}
};
//
// Render
render() {
const {
isSaving,
isResettingQualityDefinitions,
hasPendingChanges
} = this.state;
return (
<PageContent title={translate('QualitySettings')}>
<SettingsToolbarConnector
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ResetDefinitions')}
iconName={icons.REFRESH}
isSpinning={isResettingQualityDefinitions}
onPress={this.onResetQualityDefinitionsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
/>
<PageContentBody>
<QualityDefinitions
onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange}
/>
</PageContentBody>
<ResetQualityDefinitionsModal
isOpen={this.state.isConfirmQualityDefinitionResetModalOpen}
onModalClose={this.onCloseResetQualityDefinitionsModal}
/>
</PageContent>
);
}
}
Quality.propTypes = {
isResettingQualityDefinitions: PropTypes.bool.isRequired
};
export default Quality;