diff --git a/src/lib/viewers/controls/media/HDBadge.scss b/src/lib/viewers/controls/media/HDBadge.scss
new file mode 100644
index 000000000..8898a7c58
--- /dev/null
+++ b/src/lib/viewers/controls/media/HDBadge.scss
@@ -0,0 +1,11 @@
+@import '../styles';
+
+.bp-HDBadge {
+ padding: 2px 2px 2px 3px;
+ color: $white;
+ font-weight: bold;
+ font-size: 8px;
+ letter-spacing: .1em;
+ background-color: $bdl-box-blue;
+ border-radius: 3px;
+}
diff --git a/src/lib/viewers/controls/media/HDBadge.tsx b/src/lib/viewers/controls/media/HDBadge.tsx
new file mode 100644
index 000000000..6d984db0c
--- /dev/null
+++ b/src/lib/viewers/controls/media/HDBadge.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import './HDBadge.scss';
+
+export default function HDBadge(): JSX.Element {
+ return
HD
;
+}
diff --git a/src/lib/viewers/controls/media/MediaSettings.tsx b/src/lib/viewers/controls/media/MediaSettings.tsx
index 41fe76af6..dd9ef4e91 100644
--- a/src/lib/viewers/controls/media/MediaSettings.tsx
+++ b/src/lib/viewers/controls/media/MediaSettings.tsx
@@ -1,41 +1,33 @@
import React from 'react';
import noop from 'lodash/noop';
-import getLanguageName from '../../../lang';
-import MediaSettingsMenuAudioTracks, { AudioTrack, Props as AudioTracksProps } from './MediaSettingsAudioTracks';
+import MediaSettingsMenuAudioTracks, { addLabels, Props as AudioTracksProps } from './MediaSettingsAudioTracks';
import MediaSettingsMenuAutoplay, { Props as AutoplayProps } from './MediaSettingsMenuAutoplay';
+import MediaSettingsMenuQuality, {
+ getLabel as getQualityLabel,
+ Props as QualityProps,
+} from './MediaSettingsMenuQuality';
import MediaSettingsMenuRate, { Props as RateProps } from './MediaSettingsMenuRate';
-import Settings, { Menu } from '../settings';
+import Settings, { Menu, Props as SettingsProps } from '../settings';
-export type Props = Partial & AutoplayProps & RateProps & { className?: string };
-
-const generateAudioTrackLabel = (language: string, index: number): string => {
- let label = `${__('track')} ${index + 1}`;
- if (language !== 'und') {
- label = `${label} (${getLanguageName(language) || language})`;
- }
-
- return label;
-};
-
-const addLabels = (audioTracks: Array): Array =>
- audioTracks.map((track, index) => {
- const { language } = track;
- const label = generateAudioTrackLabel(language, index);
- return {
- ...track,
- label,
- };
- });
+export type Props = Partial &
+ Partial &
+ Partial &
+ AutoplayProps &
+ RateProps & { className?: string };
export default function MediaSettings({
audioTrack,
audioTracks = [],
autoplay,
+ badge,
className,
onAudioTrackChange = noop,
onAutoplayChange,
+ onQualityChange,
onRateChange,
+ quality,
rate,
+ toggle,
}: Props): JSX.Element {
const autoValue = autoplay ? __('media_autoplay_enabled') : __('media_autoplay_disabled');
const rateValue = rate === '1.0' || !rate ? __('media_speed_normal') : rate;
@@ -45,10 +37,17 @@ export default function MediaSettings({
const showAudioTrackItems = audioTracks.length > 1;
return (
-
+
+ {quality && (
+
+ )}
{showAudioTrackItems && (
)}
@@ -56,6 +55,9 @@ export default function MediaSettings({
+ {quality && onQualityChange && (
+
+ )}
{showAudioTrackItems && (
void;
};
+export const generateAudioTrackLabel = (language: string, index: number): string => {
+ let label = `${__('track')} ${index + 1}`;
+ if (language !== 'und') {
+ label = `${label} (${getLanguageName(language) || language})`;
+ }
+
+ return label;
+};
+
+export const addLabels = (audioTracks: Array): Array =>
+ audioTracks.map((track, index) => {
+ const { language } = track;
+ const label = generateAudioTrackLabel(language, index);
+ return {
+ ...track,
+ label,
+ };
+ });
+
export default function MediaSettingsMenuAudioTracks({
audioTrack,
audioTracks,
diff --git a/src/lib/viewers/controls/media/MediaSettingsMenuQuality.tsx b/src/lib/viewers/controls/media/MediaSettingsMenuQuality.tsx
new file mode 100644
index 000000000..cb565b8b9
--- /dev/null
+++ b/src/lib/viewers/controls/media/MediaSettingsMenuQuality.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import noop from 'lodash/noop';
+import Settings, { Menu } from '../settings';
+
+export type Props = {
+ onQualityChange: (quality: Quality) => void;
+ quality: Quality;
+};
+
+export enum Quality {
+ AUTO = 'auto',
+ HD = 'hd',
+ SD = 'sd',
+}
+
+const QUALITY_LABEL_MAP: Record = {
+ [Quality.AUTO]: __('media_quality_auto') as string,
+ [Quality.HD]: '1080p',
+ [Quality.SD]: '480p',
+};
+
+export const getLabel = (quality: Quality): string => QUALITY_LABEL_MAP[quality];
+
+export default function MediaSettingsMenuQuality({ onQualityChange = noop, quality }: Props): JSX.Element {
+ const { setActiveMenu } = React.useContext(Settings.Context);
+
+ const handleChange = (value: Quality): void => {
+ setActiveMenu(Menu.MAIN);
+ onQualityChange(value);
+ };
+
+ return (
+
+
+
+ isSelected={quality === Quality.SD}
+ label="480p"
+ onChange={handleChange}
+ value={Quality.SD}
+ />
+
+ isSelected={quality === Quality.HD}
+ label="1080p"
+ onChange={handleChange}
+ value={Quality.HD}
+ />
+
+ isSelected={quality === Quality.AUTO}
+ label={__('media_quality_auto')}
+ onChange={handleChange}
+ value={Quality.AUTO}
+ />
+
+ );
+}
diff --git a/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx b/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx
index e869de0fb..0edfad6db 100644
--- a/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx
+++ b/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx
@@ -6,6 +6,7 @@ import Settings from '../../settings/Settings';
import SettingsMenu from '../../settings/SettingsMenu';
import SettingsMenuItem from '../../settings/SettingsMenuItem';
import MediaSettingsMenuAudioTracks from '../MediaSettingsAudioTracks';
+import MediaSettingsMenuQuality from '../MediaSettingsMenuQuality';
describe('MediaSettings', () => {
const getWrapper = (props = {}): ShallowWrapper =>
@@ -19,6 +20,8 @@ describe('MediaSettings', () => {
/>,
);
+ const CustomToggle = (): JSX.Element => ;
+
describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();
@@ -28,12 +31,24 @@ describe('MediaSettings', () => {
expect(wrapper.exists(SettingsMenuItem)).toBe(true);
});
+ test('should pass optional props to Settings', () => {
+ const badge = custom
;
+ const wrapper = getWrapper({ badge, toggle: CustomToggle });
+ const settings = wrapper.find(Settings);
+
+ expect(settings.prop('badge')).toEqual(badge);
+ expect(settings.prop('toggle')).toEqual(CustomToggle);
+ });
+
test.each`
- menuItem | value | displayValue
- ${'autoplay'} | ${true} | ${'Enabled'}
- ${'autoplay'} | ${false} | ${'Disabled'}
- ${'rate'} | ${'1.0'} | ${'Normal'}
- ${'rate'} | ${'2.0'} | ${'2.0'}
+ menuItem | value | displayValue
+ ${'autoplay'} | ${true} | ${__('media_autoplay_enabled')}
+ ${'autoplay'} | ${false} | ${__('media_autoplay_disabled')}
+ ${'rate'} | ${'1.0'} | ${__('media_speed_normal')}
+ ${'rate'} | ${'2.0'} | ${'2.0'}
+ ${'quality'} | ${'auto'} | ${__('media_quality_auto')}
+ ${'quality'} | ${'sd'} | ${'480p'}
+ ${'quality'} | ${'hd'} | ${'1080p'}
`('should display $displayValue for the $menuItem value $value', ({ displayValue, menuItem, value }) => {
const wrapper = getWrapper({ [menuItem]: value });
@@ -64,5 +79,15 @@ describe('MediaSettings', () => {
const expectedLabel = `${__('track')} 2 (English)`;
expect(wrapper.find({ target: 'audio' }).prop('value')).toBe(expectedLabel);
});
+
+ test('should not render the quality menu item if no quality is provided', () => {
+ const wrapper = getWrapper();
+ expect(wrapper.exists(MediaSettingsMenuQuality)).toBe(false);
+ });
+
+ test('should render the quality menu if the quality is provided', () => {
+ const wrapper = getWrapper({ quality: 'auto', onQualityChange: jest.fn() });
+ expect(wrapper.exists(MediaSettingsMenuQuality)).toBe(true);
+ });
});
});
diff --git a/src/lib/viewers/controls/media/__tests__/MediaSettingsMenuQuality-test.tsx b/src/lib/viewers/controls/media/__tests__/MediaSettingsMenuQuality-test.tsx
new file mode 100644
index 000000000..7a366cb0e
--- /dev/null
+++ b/src/lib/viewers/controls/media/__tests__/MediaSettingsMenuQuality-test.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { mount, ReactWrapper } from 'enzyme';
+import MediaSettingsMenuQuality, { Quality } from '../MediaSettingsMenuQuality';
+import Settings, { Context, Menu } from '../../settings';
+
+describe('MediaSettingsMenuQuality', () => {
+ const getContext = (): Partial => ({ setActiveMenu: jest.fn() });
+ const getWrapper = (props = {}, context = getContext()): ReactWrapper =>
+ mount(, {
+ wrappingComponent: Settings.Context.Provider,
+ wrappingComponentProps: { value: context },
+ });
+
+ describe('event handlers', () => {
+ test('should surface the selected item on change', () => {
+ const onQualityChange = jest.fn();
+ const wrapper = getWrapper({ onQualityChange });
+
+ wrapper.find({ value: 'sd' }).simulate('click');
+
+ expect(onQualityChange).toBeCalledWith('sd');
+ });
+
+ test('should reset the active menu on change', () => {
+ const context = getContext();
+ const wrapper = getWrapper({}, context);
+
+ wrapper.find({ value: 'sd' }).simulate('click');
+
+ expect(context.setActiveMenu).toBeCalledWith(Menu.MAIN);
+ });
+ });
+
+ describe('render', () => {
+ test('should return a valid wrapper', () => {
+ const wrapper = getWrapper();
+ const radioItems = wrapper.find(Settings.RadioItem);
+
+ expect(wrapper.exists(Settings.MenuBack)).toBe(true);
+ expect(radioItems.length).toBe(3);
+ expect(radioItems.at(2).prop('isSelected')).toBe(true);
+ });
+ });
+});
diff --git a/src/lib/viewers/controls/media/_styles.scss b/src/lib/viewers/controls/media/_styles.scss
index 0325cc3c8..2b4f12d23 100644
--- a/src/lib/viewers/controls/media/_styles.scss
+++ b/src/lib/viewers/controls/media/_styles.scss
@@ -35,7 +35,7 @@ $bp-MediaControl-width: 36px;
right: 15px;
}
- .bp-SettingsToggle {
+ .bp-SettingsToggle-button {
width: $bp-MediaControl-width;
height: $bp-MediaControl-height;
}
diff --git a/src/lib/viewers/controls/settings/Settings.tsx b/src/lib/viewers/controls/settings/Settings.tsx
index 6a5267c96..59e29e3d8 100644
--- a/src/lib/viewers/controls/settings/Settings.tsx
+++ b/src/lib/viewers/controls/settings/Settings.tsx
@@ -5,7 +5,7 @@ import SettingsCheckboxItem from './SettingsCheckboxItem';
import SettingsContext, { Menu, Rect } from './SettingsContext';
import SettingsDropdown from './SettingsDropdown';
import SettingsFlyout from './SettingsFlyout';
-import SettingsGearToggle, { Ref as SettingsToggleRef } from './SettingsToggle';
+import SettingsGearToggle, { Props as SettingsGearToggleProps, Ref as SettingsToggleRef } from './SettingsToggle';
import SettingsMenu from './SettingsMenu';
import SettingsMenuBack from './SettingsMenuBack';
import SettingsMenuItem from './SettingsMenuItem';
@@ -13,14 +13,15 @@ import SettingsRadioItem from './SettingsRadioItem';
import useClickOutside from '../hooks/useClickOutside';
import { decodeKeydown } from '../../../util';
-export type Props = React.PropsWithChildren<{
- className?: string;
- onClose?: () => void;
- onOpen?: () => void;
- toggle?: React.ElementType;
-}>;
-
+export type Props = Pick &
+ React.PropsWithChildren<{
+ className?: string;
+ onClose?: () => void;
+ onOpen?: () => void;
+ toggle?: React.ElementType;
+ }>;
export default function Settings({
+ badge,
children,
className,
onClose = noop,
@@ -86,12 +87,7 @@ export default function Settings({
{...rest}
>
-
+
{children}
diff --git a/src/lib/viewers/controls/settings/SettingsToggle.scss b/src/lib/viewers/controls/settings/SettingsToggle.scss
index 3281943c7..129803996 100644
--- a/src/lib/viewers/controls/settings/SettingsToggle.scss
+++ b/src/lib/viewers/controls/settings/SettingsToggle.scss
@@ -1,7 +1,7 @@
@import '../styles';
.bp-SettingsToggle {
- @include bp-Control;
+ position: relative;
&.bp-is-open {
.bp-SettingsToggle-icon {
@@ -10,7 +10,20 @@
}
}
+.bp-SettingsToggle-button {
+ @include bp-Control;
+}
+
.bp-SettingsToggle-icon {
transform: rotate(0);
transition: transform 300ms ease;
}
+
+.bp-SettingsToggle-badge {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ align-items: center;
+ transform: translateY(-100%);
+}
diff --git a/src/lib/viewers/controls/settings/SettingsToggle.tsx b/src/lib/viewers/controls/settings/SettingsToggle.tsx
index ebe6b09e2..257e055d0 100644
--- a/src/lib/viewers/controls/settings/SettingsToggle.tsx
+++ b/src/lib/viewers/controls/settings/SettingsToggle.tsx
@@ -4,23 +4,27 @@ import IconGear24 from '../icons/IconGear24';
import './SettingsToggle.scss';
export type Props = {
+ badge?: React.ReactElement;
isOpen: boolean;
onClick: () => void;
};
export type Ref = HTMLButtonElement;
-function SettingsToggle({ isOpen, onClick }: Props, ref: React.Ref[): JSX.Element {
+function SettingsToggle({ badge, isOpen, onClick }: Props, ref: React.Ref][): JSX.Element {
return (
-
+ ]
+
+ {React.isValidElement(badge) &&
{badge}
}
+
);
}
diff --git a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx
index d348764e9..c55a999a2 100644
--- a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx
+++ b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx
@@ -18,7 +18,10 @@ describe('Settings', () => {
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false);
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false);
- wrapper.find(SettingsGearToggle).simulate('click');
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true);
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true);
@@ -56,7 +59,10 @@ describe('Settings', () => {
return event;
};
- wrapper.find(SettingsGearToggle).simulate('click'); // Open the controls
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true);
act(() => {
@@ -65,7 +71,11 @@ describe('Settings', () => {
wrapper.update();
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false);
- wrapper.find(SettingsGearToggle).simulate('click'); // Re-open the controls
+ // Re-open the controls
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true);
wrapper.find(SettingsFlyout).simulate('click'); // Click within the controls
@@ -94,7 +104,10 @@ describe('Settings', () => {
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false);
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false);
- wrapper.find(SettingsGearToggle).simulate('click');
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true);
expect(onOpen).toBeCalledTimes(1);
@@ -109,13 +122,19 @@ describe('Settings', () => {
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false);
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false);
- wrapper.find(SettingsGearToggle).simulate('click');
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true);
expect(onOpen).toBeCalledTimes(1);
expect(onClose).not.toBeCalled();
- wrapper.find(SettingsGearToggle).simulate('click');
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false);
expect(onOpen).toBeCalledTimes(1);
@@ -132,7 +151,10 @@ describe('Settings', () => {
return event;
};
- wrapper.find(SettingsGearToggle).simulate('click'); // Open the controls
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update(); // Open the controls
expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true);
act(() => {
@@ -158,7 +180,10 @@ describe('Settings', () => {
test('should apply activeRect dimensions if present', () => {
const wrapper = getWrapper();
- wrapper.find(SettingsGearToggle).simulate('click');
+ act(() => {
+ wrapper.find(SettingsGearToggle).prop('onClick')();
+ });
+ wrapper.update();
expect(wrapper.find(SettingsFlyout).prop('height')).toBe('auto');
expect(wrapper.find(SettingsFlyout).prop('width')).toBe('auto');
@@ -189,5 +214,23 @@ describe('Settings', () => {
expect(wrapper.exists(CustomToggleWithRef)).toBe(true);
});
});
+
+ describe('badge prop', () => {
+ function CustomBadge(): JSX.Element {
+ return custom
;
+ }
+
+ test('should not show badge if not provided', () => {
+ const wrapper = getWrapper();
+
+ expect(wrapper.exists('.bp-Settings-toggleBadge')).toBe(false);
+ });
+
+ test('should show badge if provided', () => {
+ const badge = ;
+ const wrapper = getWrapper({ badge });
+ expect(wrapper.exists('CustomBadge')).toBe(true);
+ });
+ });
});
});
diff --git a/src/lib/viewers/controls/settings/__tests__/SettingsToggle-test.tsx b/src/lib/viewers/controls/settings/__tests__/SettingsToggle-test.tsx
index af0a74cde..1b3fb85fb 100644
--- a/src/lib/viewers/controls/settings/__tests__/SettingsToggle-test.tsx
+++ b/src/lib/viewers/controls/settings/__tests__/SettingsToggle-test.tsx
@@ -13,7 +13,7 @@ describe('SettingsToggle', () => {
expect(wrapper.hasClass('bp-SettingsToggle')).toBe(true);
expect(wrapper.exists(IconGear24)).toBe(true);
- expect(wrapper.prop('title')).toBe('Settings');
+ expect(wrapper.find('button').prop('title')).toBe(__('media_settings'));
});
test.each([true, false])('should add or remove class based on isOpen prop', isOpen => {
diff --git a/src/lib/viewers/media/DashControls.scss b/src/lib/viewers/media/DashControls.scss
index a943611f6..d38b482b8 100644
--- a/src/lib/viewers/media/DashControls.scss
+++ b/src/lib/viewers/media/DashControls.scss
@@ -1,4 +1,4 @@
-@import '../controls//media/styles';
+@import '../controls/media/styles';
.bp-DashControls {
@include bp-VideoControls;
diff --git a/src/lib/viewers/media/DashControls.tsx b/src/lib/viewers/media/DashControls.tsx
index 6f1d6d055..6674da161 100644
--- a/src/lib/viewers/media/DashControls.tsx
+++ b/src/lib/viewers/media/DashControls.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import DurationLabels, { Props as DurationLabelsProps } from '../controls/media/DurationLabels';
+import HDBadge from '../controls/media/HDBadge';
import MediaFullscreenToggle, { Props as MediaFullscreenToggleProps } from '../controls/media/MediaFullscreenToggle';
import MediaSettings, { Props as MediaSettingsProps } from '../controls/media/MediaSettings';
import PlayPauseToggle, { Props as PlayControlsProps } from '../controls/media/PlayPauseToggle';
@@ -12,7 +13,7 @@ export type Props = DurationLabelsProps &
MediaSettingsProps &
PlayControlsProps &
TimeControlsProps &
- VolumeControlsProps;
+ VolumeControlsProps & { isPlayingHD?: boolean };
export default function DashControls({
audioTrack,
@@ -22,14 +23,17 @@ export default function DashControls({
currentTime,
durationTime,
isPlaying,
+ isPlayingHD,
onAudioTrackChange,
onAutoplayChange,
onFullscreenToggle,
onMuteChange,
onPlayPause,
+ onQualityChange,
onRateChange,
onTimeChange,
onVolumeChange,
+ quality,
rate,
volume,
}: Props): JSX.Element {
@@ -55,10 +59,13 @@ export default function DashControls({
audioTrack={audioTrack}
audioTracks={audioTracks}
autoplay={autoplay}
+ badge={isPlayingHD ? : undefined}
className="bp-DashControls-settings"
onAudioTrackChange={onAudioTrackChange}
onAutoplayChange={onAutoplayChange}
+ onQualityChange={onQualityChange}
onRateChange={onRateChange}
+ quality={quality}
rate={rate}
/>
diff --git a/src/lib/viewers/media/DashViewer.js b/src/lib/viewers/media/DashViewer.js
index 987266569..638af82b2 100644
--- a/src/lib/viewers/media/DashViewer.js
+++ b/src/lib/viewers/media/DashViewer.js
@@ -28,6 +28,9 @@ class DashViewer extends VideoBaseViewer {
/** @property {Array