Skip to content

Commit

Permalink
feat(controls): Audio tracks menu for Dash viewer (#1391)
Browse files Browse the repository at this point in the history
  • Loading branch information
Conrad Chan authored May 27, 2021
1 parent 6954be0 commit 688aa03
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 9 deletions.
41 changes: 40 additions & 1 deletion src/lib/viewers/controls/media/MediaSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<AudioTracksProps> & 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<AudioTrack>): Array<AudioTrack> =>
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 (
<Settings className={className}>
<Settings.Menu name={Menu.MAIN}>
<Settings.MenuItem label={__('media_autoplay')} target={Menu.AUTOPLAY} value={autoValue} />
<Settings.MenuItem label={__('media_speed')} target={Menu.RATE} value={rateValue} />
{showAudioTrackItems && (
<Settings.MenuItem label={__('media_audio')} target={Menu.AUDIO} value={audioTrackLabel} />
)}
</Settings.Menu>

<MediaSettingsMenuAutoplay autoplay={autoplay} onAutoplayChange={onAutoplayChange} />
<MediaSettingsMenuRate onRateChange={onRateChange} rate={rate} />
{showAudioTrackItems && (
<MediaSettingsMenuAudioTracks
audioTrack={audioTrack}
audioTracks={labelledAudioTracks}
onAudioTrackChange={onAudioTrackChange}
/>
)}
</Settings>
);
}
43 changes: 43 additions & 0 deletions src/lib/viewers/controls/media/MediaSettingsAudioTracks.tsx
Original file line number Diff line number Diff line change
@@ -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<AudioTrack>;
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 (
<Settings.Menu name={Menu.AUDIO}>
<Settings.MenuBack label={__('media_audio')} />
{audioTracks.map(({ id, label }) => (
<Settings.RadioItem
key={id}
isSelected={audioTrack === id}
label={label}
onChange={handleChange}
value={id}
/>
))}
</Settings.Menu>
);
}
6 changes: 6 additions & 0 deletions src/lib/viewers/controls/media/__mocks__/audioTracks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const audioTracks = [
{ id: 0, label: '', language: 'und', role: 'audio0' },
{ id: 1, label: '', language: 'en', role: 'audio1' },
];

export default audioTracks;
27 changes: 27 additions & 0 deletions src/lib/viewers/controls/media/__tests__/MediaSettings-test.tsx
Original file line number Diff line number Diff line change
@@ -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 =>
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<Context> => ({ setActiveMenu: jest.fn() });
const getWrapper = (props = {}, context = getContext()): ReactWrapper =>
mount(
<MediaSettingsAudioTracks
audioTrack={1}
audioTracks={audioTracks}
onAudioTrackChange={jest.fn()}
{...props}
/>,
{
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);
});
});
});
3 changes: 3 additions & 0 deletions src/lib/viewers/controls/settings/SettingsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/lib/viewers/media/DashControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ export type Props = DurationLabelsProps &
VolumeControlsProps;

export default function DashControls({
audioTrack,
audioTracks,
autoplay,
bufferedRange,
currentTime,
durationTime,
isPlaying,
onAudioTrackChange,
onAutoplayChange,
onFullscreenToggle,
onMuteChange,
Expand Down Expand Up @@ -49,8 +52,11 @@ export default function DashControls({
<div className="bp-DashControls-group">
{/* CC Toggle */}
<MediaSettings
audioTrack={audioTrack}
audioTracks={audioTracks}
autoplay={autoplay}
className="bp-DashControls-settings"
onAudioTrackChange={onAudioTrackChange}
onAutoplayChange={onAutoplayChange}
onRateChange={onRateChange}
rate={rate}
Expand Down
37 changes: 33 additions & 4 deletions src/lib/viewers/media/DashViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ class DashViewer extends VideoBaseViewer {
/** @property {Object} - shakaExtern.TextDisplayer that displays auto-generated captions, if available */
autoCaptionDisplayer;

/** @property {Array<Object>} - Array of audio tracks for the video */
audioTracks = [];

/** @property {string} - ID of the selected audio track */
selectedAudioTrack;

/**
* @inheritdoc
*/
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -663,14 +670,19 @@ class DashViewer extends VideoBaseViewer {
}

this.audioTracks = uniqueAudioVariants.map(track => ({
id: track.audioId,
language: track.language,
role: track.roles[0],
}));

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);
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand All @@ -934,11 +960,14 @@ class DashViewer extends VideoBaseViewer {

this.controls.render(
<DashControls
audioTrack={this.selectedAudioTrack}
audioTracks={this.audioTracks}
autoplay={this.isAutoplayEnabled()}
bufferedRange={this.mediaEl.buffered}
currentTime={this.mediaEl.currentTime}
durationTime={this.mediaEl.duration}
isPlaying={!this.mediaEl.paused}
onAudioTrackChange={this.setAudioTrack}
onAutoplayChange={this.setAutoplay}
onFullscreenToggle={this.toggleFullscreen}
onMuteChange={this.toggleMute}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/viewers/media/__tests__/DashControls-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import VolumeControls from '../../controls/media/VolumeControls';
describe('DashControls', () => {
describe('render', () => {
test('should return a valid wrapper', () => {
const onAudioTrackChange = jest.fn();
const onAutoplayChange = jest.fn();
const onFullscreenToggle = jest.fn();
const onMuteChange = jest.fn();
Expand All @@ -19,7 +20,10 @@ describe('DashControls', () => {
const onVolumeChange = jest.fn();
const wrapper = shallow(
<DashControls
audioTrack={1}
audioTracks={[]}
autoplay={false}
onAudioTrackChange={onAudioTrackChange}
onAutoplayChange={onAutoplayChange}
onFullscreenToggle={onFullscreenToggle}
onMuteChange={onMuteChange}
Expand All @@ -33,6 +37,7 @@ describe('DashControls', () => {

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);
Expand Down
Loading

0 comments on commit 688aa03

Please sign in to comment.