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} - Array of audio tracks for the video */ audioTracks = []; + /** @property {string} - Video playback quality */ + selectedQuality = 'sd'; + /** @property {string} - ID of the selected audio track */ selectedAudioTrack; @@ -49,6 +52,7 @@ class DashViewer extends VideoBaseViewer { this.requestFilter = this.requestFilter.bind(this); this.restartPlayback = this.restartPlayback.bind(this); this.setAudioTrack = this.setAudioTrack.bind(this); + this.setQuality = this.setQuality.bind(this); this.shakaErrorHandler = this.shakaErrorHandler.bind(this); } @@ -442,7 +446,9 @@ class DashViewer extends VideoBaseViewer { break; } - this.showGearHdIcon(this.getActiveTrack()); + if (!this.getViewerOption('useReactControls')) { + this.showGearHdIcon(this.getActiveTrack()); + } if (quality) { this.emit('qualitychange', quality); @@ -466,6 +472,15 @@ class DashViewer extends VideoBaseViewer { } } + /** + * Determines whether the player is playing HD currently + * @returns {Boolean} + */ + isPlayingHD() { + const activeTrack = this.getActiveTrack(); + return activeTrack.videoId === this.hdVideoId; + } + /** * Handles adaptation changes * @@ -476,7 +491,9 @@ class DashViewer extends VideoBaseViewer { adaptationHandler() { const activeTrack = this.getActiveTrack(); - this.showGearHdIcon(activeTrack); + if (!this.getViewerOption('useReactControls')) { + this.showGearHdIcon(activeTrack); + } if (!this.isLoaded()) { return; @@ -485,6 +502,10 @@ class DashViewer extends VideoBaseViewer { this.emit('adaptation', activeTrack.bandwidth); } this.hideLoadingIcon(); + + if (this.getViewerOption('useReactControls')) { + this.renderUI(); + } } /** @@ -746,6 +767,17 @@ class DashViewer extends VideoBaseViewer { } } + /** + * @inheritdoc + */ + loadUIReact() { + super.loadUIReact(); + + const isHDSupported = this.hdVideoId !== -1; + this.selectedQuality = isHDSupported ? this.cache.get('media-quality') || 'auto' : 'sd'; + this.setQuality(this.selectedQuality); + } + /** * Loads the film strip * @@ -950,6 +982,39 @@ class DashViewer extends VideoBaseViewer { } } + /** + * Updates the selected quality and updates the player accordingly + * @param {string} quality - 'sd', 'hd', or 'auto' + * @emits qualitychange + * @return {void} + */ + setQuality(quality) { + const newQuality = quality !== 'sd' && quality !== 'hd' ? 'auto' : quality; + this.cache.set('media-quality', newQuality, true); + this.selectedQuality = newQuality; + + switch (newQuality) { + case 'hd': + this.enableAdaptation(false); + this.enableVideoId(this.hdVideoId); + break; + case 'sd': + this.enableAdaptation(false); + this.enableVideoId(this.sdVideoId); + break; + case 'auto': + default: + this.enableAdaptation(true); + break; + } + + if (newQuality) { + this.emit('qualitychange', newQuality); + } + + this.renderUI(); + } + /** * @inheritdoc */ @@ -967,14 +1032,17 @@ class DashViewer extends VideoBaseViewer { currentTime={this.mediaEl.currentTime} durationTime={this.mediaEl.duration} isPlaying={!this.mediaEl.paused} + isPlayingHD={this.isPlayingHD()} onAudioTrackChange={this.setAudioTrack} onAutoplayChange={this.setAutoplay} onFullscreenToggle={this.toggleFullscreen} onMuteChange={this.toggleMute} onPlayPause={this.togglePlay} + onQualityChange={this.setQuality} onRateChange={this.setRate} onTimeChange={this.handleTimeupdateFromMediaControls} onVolumeChange={this.setVolume} + quality={this.selectedQuality} rate={this.getRate()} volume={this.mediaEl.volume} />, diff --git a/src/lib/viewers/media/__tests__/DashControls-test.tsx b/src/lib/viewers/media/__tests__/DashControls-test.tsx index 6a58136a2..d2248c9cf 100644 --- a/src/lib/viewers/media/__tests__/DashControls-test.tsx +++ b/src/lib/viewers/media/__tests__/DashControls-test.tsx @@ -1,49 +1,89 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import DashControls from '../DashControls'; +import { shallow, ShallowWrapper } from 'enzyme'; +import DashControls, { Props } from '../DashControls'; +import HDBadge from '../../controls/media/HDBadge'; import MediaFullscreenToggle from '../../controls/media/MediaFullscreenToggle'; import MediaSettings from '../../controls/media/MediaSettings'; import PlayPauseToggle from '../../controls/media/PlayPauseToggle'; import TimeControls from '../../controls/media/TimeControls'; import VolumeControls from '../../controls/media/VolumeControls'; +import { Quality } from '../../controls/media/MediaSettingsMenuQuality'; describe('DashControls', () => { describe('render', () => { + function CustomBadge(): JSX.Element { + return
custom
; + } + + const getDefaults = (): Props => ({ + audioTrack: 1, + audioTracks: [], + autoplay: false, + isPlaying: false, + isPlayingHD: false, + onAudioTrackChange: jest.fn(), + onAutoplayChange: jest.fn(), + onFullscreenToggle: jest.fn(), + onMuteChange: jest.fn(), + onPlayPause: jest.fn(), + onQualityChange: jest.fn(), + onRateChange: jest.fn(), + onTimeChange: jest.fn(), + onVolumeChange: jest.fn(), + quality: Quality.AUTO, + rate: '1.0', + }); + const getWrapper = (props = {}): ShallowWrapper => shallow(); + test('should return a valid wrapper', () => { const onAudioTrackChange = jest.fn(); const onAutoplayChange = jest.fn(); const onFullscreenToggle = jest.fn(); const onMuteChange = jest.fn(); + const onQualityChange = jest.fn(); const onRateChange = jest.fn(); const onPlayPause = jest.fn(); const onTimeChange = jest.fn(); const onVolumeChange = jest.fn(); - const wrapper = shallow( - , - ); + const wrapper = getWrapper({ + onAudioTrackChange, + onAutoplayChange, + onFullscreenToggle, + onMuteChange, + onPlayPause, + onQualityChange, + onRateChange, + onTimeChange, + onVolumeChange, + }); expect(wrapper.hasClass('bp-DashControls')).toBe(true); expect(wrapper.find(MediaFullscreenToggle).prop('onFullscreenToggle')).toEqual(onFullscreenToggle); - expect(wrapper.find(MediaSettings).prop('onAudioTrackChange')).toEqual(onAudioTrackChange); - expect(wrapper.find(MediaSettings).prop('onAutoplayChange')).toEqual(onAutoplayChange); - expect(wrapper.find(MediaSettings).prop('onRateChange')).toEqual(onRateChange); + expect(wrapper.find(MediaSettings).props()).toMatchObject({ + audioTrack: 1, + audioTracks: [], + autoplay: false, + onAudioTrackChange, + onAutoplayChange, + onQualityChange, + onRateChange, + quality: 'auto', + rate: '1.0', + }); expect(wrapper.find(PlayPauseToggle).prop('onPlayPause')).toEqual(onPlayPause); expect(wrapper.find(TimeControls).prop('onTimeChange')).toEqual(onTimeChange); expect(wrapper.find(VolumeControls).prop('onMuteChange')).toEqual(onMuteChange); expect(wrapper.find(VolumeControls).prop('onVolumeChange')).toEqual(onVolumeChange); }); + + test('should not pass along badge if not playing HD', () => { + const wrapper = getWrapper({ badge: }); + expect(wrapper.find(MediaSettings).prop('badge')).toBeUndefined(); + }); + + test('should pass along badge if playing HD', () => { + const wrapper = getWrapper({ isPlayingHD: true }); + expect(wrapper.find(MediaSettings).prop('badge')).toEqual(); + }); }); }); diff --git a/src/lib/viewers/media/__tests__/DashViewer-test.js b/src/lib/viewers/media/__tests__/DashViewer-test.js index 24479923b..b08984c3f 100644 --- a/src/lib/viewers/media/__tests__/DashViewer-test.js +++ b/src/lib/viewers/media/__tests__/DashViewer-test.js @@ -493,6 +493,16 @@ describe('lib/viewers/media/DashViewer', () => { expect(stubs.adapt).toBeCalledWith(false); expect(dash.emit).toBeCalledWith('qualitychange', 'sd'); }); + + describe('With React controls', () => { + test('should not call showGearHdIcon', () => { + jest.spyOn(dash, 'getViewerOption').mockImplementation(() => true); + + dash.handleQuality(); + + expect(dash.showGearHdIcon).not.toBeCalled(); + }); + }); }); describe('adaptationHandler()', () => { @@ -538,6 +548,15 @@ describe('lib/viewers/media/DashViewer', () => { expect(dash.emit).not.toBeCalled(); expect(stubs.hide).toBeCalled(); }); + + describe('With React controls', () => { + test('should call renderUI', () => { + jest.spyOn(dash, 'getViewerOption').mockImplementation(() => true); + jest.spyOn(dash, 'renderUI').mockImplementation(); + dash.adaptationHandler(); + expect(dash.renderUI).toBeCalled(); + }); + }); }); describe('shakaErrorHandler()', () => { @@ -720,6 +739,39 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('loadUIReact()', () => { + beforeEach(() => { + dash.hdVideoId = 123; + jest.spyOn(dash, 'setQuality').mockImplementation(); + jest.spyOn(VideoBaseViewer.prototype, 'loadUIReact').mockImplementation(); + }); + + test('should set quality to sd if HD is not supported', () => { + dash.hdVideoId = -1; + + dash.loadUIReact(); + + expect(dash.selectedQuality).toBe('sd'); + expect(dash.setQuality).toBeCalledWith('sd'); + }); + + test('should set quality to auto if HD is supported and cache has no entry', () => { + dash.loadUIReact(); + + expect(dash.selectedQuality).toBe('auto'); + expect(dash.setQuality).toBeCalledWith('auto'); + }); + + test('should set quality to cache value if HD is supported and cache has an entry', () => { + jest.spyOn(dash.cache, 'get').mockReturnValue('hd'); + + dash.loadUIReact(); + + expect(dash.selectedQuality).toBe('hd'); + expect(dash.setQuality).toBeCalledWith('hd'); + }); + }); + describe('loadFilmStrip()', () => { beforeEach(() => { dash.options = { @@ -1496,11 +1548,61 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('setQuality()', () => { + const HD_VIDEO_ID = 1; + const SD_VIDEO_ID = -1; + + beforeEach(() => { + dash.hdVideoId = HD_VIDEO_ID; + dash.sdVideoId = SD_VIDEO_ID; + jest.spyOn(dash, 'emit').mockImplementation(); + jest.spyOn(dash, 'enableVideoId').mockImplementation(); + jest.spyOn(dash, 'enableAdaptation').mockImplementation(); + jest.spyOn(dash, 'renderUI').mockImplementation(); + jest.spyOn(dash.cache, 'set').mockImplementation(); + }); + + test.each` + quality | videoId + ${'sd'} | ${SD_VIDEO_ID} + ${'hd'} | ${HD_VIDEO_ID} + `('should set the quality to $quality', ({ quality, videoId }) => { + dash.setQuality(quality); + expect(dash.selectedQuality).toBe(quality); + expect(dash.cache.set).toBeCalledWith('media-quality', quality, true); + expect(dash.enableAdaptation).toBeCalledWith(false); + expect(dash.enableVideoId).toBeCalledWith(videoId); + expect(dash.emit).toBeCalledWith('qualitychange', quality); + expect(dash.renderUI).toBeCalled(); + }); + + test('should set the quality to auto', () => { + dash.setQuality('auto'); + expect(dash.selectedQuality).toBe('auto'); + expect(dash.cache.set).toBeCalledWith('media-quality', 'auto', true); + expect(dash.enableAdaptation).toBeCalledWith(true); + expect(dash.enableVideoId).not.toBeCalled(); + expect(dash.emit).toBeCalledWith('qualitychange', 'auto'); + expect(dash.renderUI).toBeCalled(); + }); + + test('should set unknown quality to auto', () => { + dash.setQuality('unknown'); + expect(dash.selectedQuality).toBe('auto'); + expect(dash.cache.set).toBeCalledWith('media-quality', 'auto', true); + expect(dash.enableAdaptation).toBeCalledWith(true); + expect(dash.enableVideoId).not.toBeCalled(); + expect(dash.emit).toBeCalledWith('qualitychange', 'auto'); + expect(dash.renderUI).toBeCalled(); + }); + }); + describe('renderUI()', () => { const getProps = instance => instance.controls.render.mock.calls[0][0].props; beforeEach(() => { jest.spyOn(dash, 'getViewerOption').mockImplementation(() => true); + jest.spyOn(dash, 'isPlayingHD').mockImplementation(() => false); dash.controls = { destroy: jest.fn(), render: jest.fn(), @@ -1516,14 +1618,17 @@ describe('lib/viewers/media/DashViewer', () => { autoplay: false, currentTime: expect.any(Number), isPlaying: expect.any(Boolean), + isPlayingHD: false, onAudioTrackChange: dash.setAudioTrack, onAutoplayChange: dash.setAutoplay, onFullscreenToggle: dash.toggleFullscreen, onMuteChange: dash.toggleMute, onPlayPause: dash.togglePlay, + onQualityChange: dash.setQuality, onRateChange: dash.setRate, onTimeChange: dash.handleTimeupdateFromMediaControls, onVolumeChange: dash.setVolume, + quality: 'sd', rate: '1.0', volume: expect.any(Number), });