From 688aa035d562b870dd24a3083f0f31e7c2244d3e Mon Sep 17 00:00:00 2001 From: Conrad Chan Date: Thu, 27 May 2021 10:41:02 -0700 Subject: [PATCH] feat(controls): Audio tracks menu for Dash viewer (#1391) --- .../viewers/controls/media/MediaSettings.tsx | 41 ++++++++++++++- .../media/MediaSettingsAudioTracks.tsx | 43 ++++++++++++++++ .../controls/media/__mocks__/audioTracks.ts | 6 +++ .../media/__tests__/MediaSettings-test.tsx | 27 ++++++++++ .../MediaSettingsAudioTracks-test.tsx | 51 +++++++++++++++++++ .../controls/settings/SettingsContext.ts | 3 ++ src/lib/viewers/media/DashControls.tsx | 6 +++ src/lib/viewers/media/DashViewer.js | 37 ++++++++++++-- .../media/__tests__/DashControls-test.tsx | 5 ++ .../media/__tests__/DashViewer-test.js | 41 +++++++++++++-- 10 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 src/lib/viewers/controls/media/MediaSettingsAudioTracks.tsx create mode 100644 src/lib/viewers/controls/media/__mocks__/audioTracks.ts create mode 100644 src/lib/viewers/controls/media/__tests__/MediaSettingsAudioTracks-test.tsx diff --git a/src/lib/viewers/controls/media/MediaSettings.tsx b/src/lib/viewers/controls/media/MediaSettings.tsx index fc0a62714..41fe76af6 100644 --- a/src/lib/viewers/controls/media/MediaSettings.tsx +++ b/src/lib/viewers/controls/media/MediaSettings.tsx @@ -1,29 +1,68 @@ import React from 'react'; +import noop from 'lodash/noop'; +import getLanguageName from '../../../lang'; +import MediaSettingsMenuAudioTracks, { AudioTrack, Props as AudioTracksProps } from './MediaSettingsAudioTracks'; import MediaSettingsMenuAutoplay, { Props as AutoplayProps } from './MediaSettingsMenuAutoplay'; import MediaSettingsMenuRate, { Props as RateProps } from './MediaSettingsMenuRate'; import Settings, { Menu } from '../settings'; -export type Props = AutoplayProps & RateProps & { className?: string }; +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 default function MediaSettings({ + audioTrack, + audioTracks = [], autoplay, className, + onAudioTrackChange = noop, onAutoplayChange, onRateChange, rate, }: Props): JSX.Element { const autoValue = autoplay ? __('media_autoplay_enabled') : __('media_autoplay_disabled'); const rateValue = rate === '1.0' || !rate ? __('media_speed_normal') : rate; + const labelledAudioTracks = React.useMemo(() => addLabels(audioTracks), [audioTracks]); + const hydratedSelectedAudioTrack = labelledAudioTracks.find(({ id }) => audioTrack === id); + const audioTrackLabel = hydratedSelectedAudioTrack ? hydratedSelectedAudioTrack.label : ''; + const showAudioTrackItems = audioTracks.length > 1; return ( + {showAudioTrackItems && ( + + )} + {showAudioTrackItems && ( + + )} ); } diff --git a/src/lib/viewers/controls/media/MediaSettingsAudioTracks.tsx b/src/lib/viewers/controls/media/MediaSettingsAudioTracks.tsx new file mode 100644 index 000000000..97477f5b2 --- /dev/null +++ b/src/lib/viewers/controls/media/MediaSettingsAudioTracks.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Settings, { Menu } from '../settings'; + +export type AudioTrack = { + id: number; + label: string; + language: string; + role: string; +}; + +export type Props = { + audioTrack?: number; + audioTracks: Array; + onAudioTrackChange: (id: number) => void; +}; + +export default function MediaSettingsMenuAudioTracks({ + audioTrack, + audioTracks, + onAudioTrackChange, +}: Props): JSX.Element { + const { setActiveMenu } = React.useContext(Settings.Context); + + const handleChange = (value: number): void => { + setActiveMenu(Menu.MAIN); + onAudioTrackChange(value); + }; + + return ( + + + {audioTracks.map(({ id, label }) => ( + + ))} + + ); +} diff --git a/src/lib/viewers/controls/media/__mocks__/audioTracks.ts b/src/lib/viewers/controls/media/__mocks__/audioTracks.ts new file mode 100644 index 000000000..c342cbac3 --- /dev/null +++ b/src/lib/viewers/controls/media/__mocks__/audioTracks.ts @@ -0,0 +1,6 @@ +const audioTracks = [ + { id: 0, label: '', language: 'und', role: 'audio0' }, + { id: 1, label: '', language: 'en', role: 'audio1' }, +]; + +export default audioTracks; diff --git a/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx b/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx index d54ab95df..e869de0fb 100644 --- a/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx +++ b/src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; +import audioTracks from '../__mocks__/audioTracks'; import MediaSettings from '../MediaSettings'; import Settings from '../../settings/Settings'; import SettingsMenu from '../../settings/SettingsMenu'; import SettingsMenuItem from '../../settings/SettingsMenuItem'; +import MediaSettingsMenuAudioTracks from '../MediaSettingsAudioTracks'; describe('MediaSettings', () => { const getWrapper = (props = {}): ShallowWrapper => @@ -37,5 +39,30 @@ describe('MediaSettings', () => { expect(wrapper.find({ target: menuItem }).prop('value')).toBe(displayValue); }); + + test('should not render the audio menu item if no audio tracks are provided', () => { + const wrapper = getWrapper(); + expect(wrapper.exists({ target: 'audio' })).toBe(false); + expect(wrapper.exists(MediaSettingsMenuAudioTracks)).toBe(false); + }); + + test('should not render the audio menu item if only 1 audio track is present', () => { + const singleAudioTrack = [{ id: 0, language: 'und' }]; + const wrapper = getWrapper({ audioTracks: singleAudioTrack }); + expect(wrapper.exists({ target: 'audio' })).toBe(false); + expect(wrapper.exists(MediaSettingsMenuAudioTracks)).toBe(false); + }); + + test('should render the audio menu if > 1 audio tracks are present', () => { + const wrapper = getWrapper({ audioTracks }); + expect(wrapper.exists({ target: 'audio' })).toBe(true); + expect(wrapper.exists(MediaSettingsMenuAudioTracks)).toBe(true); + }); + + test('should display the generated track label for the selected audio track', () => { + const wrapper = getWrapper({ audioTrack: 1, audioTracks }); + const expectedLabel = `${__('track')} 2 (English)`; + expect(wrapper.find({ target: 'audio' }).prop('value')).toBe(expectedLabel); + }); }); }); diff --git a/src/lib/viewers/controls/media/__tests__/MediaSettingsAudioTracks-test.tsx b/src/lib/viewers/controls/media/__tests__/MediaSettingsAudioTracks-test.tsx new file mode 100644 index 000000000..d7797c7df --- /dev/null +++ b/src/lib/viewers/controls/media/__tests__/MediaSettingsAudioTracks-test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import audioTracks from '../__mocks__/audioTracks'; +import MediaSettingsAudioTracks from '../MediaSettingsAudioTracks'; +import Settings, { Context, Menu } from '../../settings'; + +describe('MediaSettingsAudioTracks', () => { + 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 onAudioTrackChange = jest.fn(); + const wrapper = getWrapper({ onAudioTrackChange }); + + wrapper.find({ value: 0 }).simulate('click'); + + expect(onAudioTrackChange).toBeCalledWith(0); + }); + + test('should reset the active menu on change', () => { + const context = getContext(); + const wrapper = getWrapper({}, context); + + wrapper.find({ value: 0 }).simulate('click'); + + expect(context.setActiveMenu).toBeCalledWith(Menu.MAIN); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.exists(Settings.MenuBack)).toBe(true); + expect(wrapper.exists(Settings.RadioItem)).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/settings/SettingsContext.ts b/src/lib/viewers/controls/settings/SettingsContext.ts index 608fc95d9..c6067c286 100644 --- a/src/lib/viewers/controls/settings/SettingsContext.ts +++ b/src/lib/viewers/controls/settings/SettingsContext.ts @@ -9,8 +9,11 @@ export type Context = { export enum Menu { MAIN = 'main', + AUDIO = 'audio', AUTOPLAY = 'autoplay', + QUALITY = 'quality', RATE = 'rate', + SUBTITLES = 'subtitles', } export type Rect = ClientRect; diff --git a/src/lib/viewers/media/DashControls.tsx b/src/lib/viewers/media/DashControls.tsx index e7af8c605..6f1d6d055 100644 --- a/src/lib/viewers/media/DashControls.tsx +++ b/src/lib/viewers/media/DashControls.tsx @@ -15,11 +15,14 @@ export type Props = DurationLabelsProps & VolumeControlsProps; export default function DashControls({ + audioTrack, + audioTracks, autoplay, bufferedRange, currentTime, durationTime, isPlaying, + onAudioTrackChange, onAutoplayChange, onFullscreenToggle, onMuteChange, @@ -49,8 +52,11 @@ export default function DashControls({
{/* CC Toggle */} } - Array of audio tracks for the video */ + audioTracks = []; + + /** @property {string} - ID of the selected audio track */ + selectedAudioTrack; + /** * @inheritdoc */ @@ -34,15 +40,16 @@ class DashViewer extends VideoBaseViewer { this.api = options.api; // Bind context for callbacks this.adaptationHandler = this.adaptationHandler.bind(this); - this.handleBuffering = this.handleBuffering.bind(this); this.getBandwidthInterval = this.getBandwidthInterval.bind(this); this.handleAudioTrack = this.handleAudioTrack.bind(this); + this.handleBuffering = this.handleBuffering.bind(this); this.handleQuality = this.handleQuality.bind(this); this.handleSubtitle = this.handleSubtitle.bind(this); this.loadeddataHandler = this.loadeddataHandler.bind(this); this.requestFilter = this.requestFilter.bind(this); - this.shakaErrorHandler = this.shakaErrorHandler.bind(this); this.restartPlayback = this.restartPlayback.bind(this); + this.setAudioTrack = this.setAudioTrack.bind(this); + this.shakaErrorHandler = this.shakaErrorHandler.bind(this); } /** @@ -663,6 +670,7 @@ class DashViewer extends VideoBaseViewer { } this.audioTracks = uniqueAudioVariants.map(track => ({ + id: track.audioId, language: track.language, role: track.roles[0], })); @@ -670,7 +678,11 @@ class DashViewer extends VideoBaseViewer { if (this.audioTracks.length > 1) { // translate the language first const languages = this.audioTracks.map(track => getLanguageName(track.language) || track.language); - this.mediaControls.initAlternateAudio(languages); + this.selectedAudioTrack = this.audioTracks[0].id; + + if (!this.getViewerOption('useReactControls')) { + this.mediaControls.initAlternateAudio(languages); + } } } @@ -705,8 +717,8 @@ class DashViewer extends VideoBaseViewer { this.startBandwidthTracking(); if (!this.getViewerOption('useReactControls')) { this.loadSubtitles(); - this.loadAlternateAudio(); } + this.loadAlternateAudio(); this.showPlayButton(); this.loaded = true; @@ -924,6 +936,20 @@ class DashViewer extends VideoBaseViewer { return super.onKeydown(key); } + /** + * Updates the selected audio track + * @param {string} audioTrackId - The selected audio track id + * @return {void} + */ + setAudioTrack(audioTrackId) { + const newAudioTrack = this.audioTracks.find(({ id }) => audioTrackId === id); + if (newAudioTrack) { + this.enableAudioId(newAudioTrack.role); + this.selectedAudioTrack = audioTrackId; + this.renderUI(); + } + } + /** * @inheritdoc */ @@ -934,11 +960,14 @@ class DashViewer extends VideoBaseViewer { this.controls.render( { describe('render', () => { test('should return a valid wrapper', () => { + const onAudioTrackChange = jest.fn(); const onAutoplayChange = jest.fn(); const onFullscreenToggle = jest.fn(); const onMuteChange = jest.fn(); @@ -19,7 +20,10 @@ describe('DashControls', () => { const onVolumeChange = jest.fn(); const wrapper = shallow( { 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(PlayPauseToggle).prop('onPlayPause')).toEqual(onPlayPause); diff --git a/src/lib/viewers/media/__tests__/DashViewer-test.js b/src/lib/viewers/media/__tests__/DashViewer-test.js index e45aec57f..24479923b 100644 --- a/src/lib/viewers/media/__tests__/DashViewer-test.js +++ b/src/lib/viewers/media/__tests__/DashViewer-test.js @@ -685,7 +685,7 @@ describe('lib/viewers/media/DashViewer', () => { expect(dash.loadUI).not.toBeCalled(); expect(dash.loadFilmStrip).not.toBeCalled(); expect(dash.loadSubtitles).not.toBeCalled(); - expect(dash.loadAlternateAudio).not.toBeCalled(); + expect(dash.loadAlternateAudio).toBeCalled(); }); }); }); @@ -936,8 +936,8 @@ describe('lib/viewers/media/DashViewer', () => { dash.loadAlternateAudio(); expect(dash.audioTracks).toEqual([ - { language: 'eng', role: 'audio0' }, - { language: 'rus', role: 'audio1' }, + { id: 0, language: 'eng', role: 'audio0' }, + { id: 1, language: 'rus', role: 'audio1' }, ]); }); @@ -965,7 +965,7 @@ describe('lib/viewers/media/DashViewer', () => { dash.loadAlternateAudio(); - expect(dash.audioTracks).toEqual([{ language: 'eng', role: 'audio0' }]); + expect(dash.audioTracks).toEqual([{ id: 0, language: 'eng', role: 'audio0' }]); }); }); @@ -1466,6 +1466,36 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('setAudioTrack()', () => { + beforeEach(() => { + jest.spyOn(dash, 'enableAudioId').mockImplementation(); + jest.spyOn(dash, 'renderUI').mockImplementation(); + dash.controls = { + destroy: jest.fn(), + render: jest.fn(), + }; + dash.audioTracks = [ + { id: 0, language: 'eng', role: 'audio0' }, + { id: 1, language: 'rus', role: 'audio1' }, + ]; + }); + + test('should do nothing if the audioTrackId is not found', () => { + dash.setAudioTrack(-1); + + expect(dash.enableAudioId).not.toBeCalled(); + expect(dash.renderUI).not.toBeCalled(); + }); + + test('should update the UI', () => { + dash.setAudioTrack(1); + + expect(dash.enableAudioId).toBeCalled(); + expect(dash.renderUI).toBeCalled(); + expect(dash.selectedAudioTrack).toBe(1); + }); + }); + describe('renderUI()', () => { const getProps = instance => instance.controls.render.mock.calls[0][0].props; @@ -1481,9 +1511,12 @@ describe('lib/viewers/media/DashViewer', () => { dash.renderUI(); expect(getProps(dash)).toMatchObject({ + audioTrack: undefined, + audioTracks: [], autoplay: false, currentTime: expect.any(Number), isPlaying: expect.any(Boolean), + onAudioTrackChange: dash.setAudioTrack, onAutoplayChange: dash.setAutoplay, onFullscreenToggle: dash.toggleFullscreen, onMuteChange: dash.toggleMute,