diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index 549e83a61..951cf365a 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -88,6 +88,10 @@ of=of subtitles=Subtitles # Label for turning off subtitles in media player off=Off +# Label for audio tracks in media player +media_audio=Audio +# Label for alternate audio tracks in media player +track=Track # 3D Preview diff --git a/src/lib/viewers/media/DashViewer.js b/src/lib/viewers/media/DashViewer.js index a1c258c21..c2d18d622 100644 --- a/src/lib/viewers/media/DashViewer.js +++ b/src/lib/viewers/media/DashViewer.js @@ -12,6 +12,8 @@ const CSS_CLASS_HD = 'bp-media-controls-is-hd'; const SEGMENT_SIZE = 5; const MAX_BUFFER = SEGMENT_SIZE * 12; // 60 sec const MANIFEST = 'manifest.mpd'; +const DEFAULT_VIDEO_WIDTH_PX = 854; +const DEFAULT_VIDEO_HEIGHT_PX = 480; @autobind class DashViewer extends VideoBaseViewer { @@ -27,9 +29,10 @@ class DashViewer extends VideoBaseViewer { this.switchHistory = []; // tracks - this.hdRepresentation = {}; - this.sdRepresentation = {}; + this.hdVideoId = -1; + this.sdVideoId = -1; this.textTracks = []; // Must be sorted by representation id + this.audioTracks = []; // dash specific class this.wrapperEl.classList.add(CSS_CLASS_DASH); @@ -191,25 +194,21 @@ class DashViewer extends VideoBaseViewer { } /** - * Handler for hd video + * Given a videoId (e.g. hd video id), enables the track with that video ID + * while maintaining the SAME AUDIO as the active track. * * @private + * @param {number} videoId - The id of the video used in the variant (provided by Shaka) * @return {void} */ - enableHD() { - this.showLoadingIcon(this.hdRepresentation.id); - this.player.selectVariantTrack(this.hdRepresentation, true); - } - - /** - * Handler for sd video - * - * @private - * @return {void} - */ - enableSD() { - this.showLoadingIcon(this.sdRepresentation.id); - this.player.selectVariantTrack(this.sdRepresentation, true); + enableVideoId(videoId) { + const tracks = this.player.getVariantTracks(); + const activeTrack = this.getActiveTrack(); + const newTrack = tracks.find((track) => track.videoId === videoId && track.audioId === activeTrack.audioId); + if (newTrack && newTrack.id !== activeTrack.id) { + this.showLoadingIcon(newTrack.id); + this.player.selectVariantTrack(newTrack, true); + } } /** @@ -244,6 +243,21 @@ class DashViewer extends VideoBaseViewer { } } + /** + * Handler for audio track + * + * @private + * @emits audiochange + * @return {void} + */ + handleAudioTrack() { + const audioIdx = parseInt(this.cache.get('media-audiotracks'), 10); + if (this.audioTracks[audioIdx] !== undefined) { + const track = this.audioTracks[audioIdx]; + this.player.selectAudioLanguage(track.language, track.role); + } + } + /** * Handler for hd/sd/auto video * @@ -257,11 +271,11 @@ class DashViewer extends VideoBaseViewer { switch (quality) { case 'hd': this.enableAdaptation(false); - this.enableHD(); + this.enableVideoId(this.hdVideoId); break; case 'sd': this.enableAdaptation(false); - this.enableSD(); + this.enableVideoId(this.sdVideoId); break; case 'auto': default: @@ -283,7 +297,7 @@ class DashViewer extends VideoBaseViewer { */ adaptationHandler() { const activeTrack = this.getActiveTrack(); - if (activeTrack.id === this.hdRepresentation.id) { + if (activeTrack.videoId === this.hdVideoId) { this.wrapperEl.classList.add(CSS_CLASS_HD); } else { this.wrapperEl.classList.remove(CSS_CLASS_HD); @@ -329,6 +343,7 @@ class DashViewer extends VideoBaseViewer { super.addEventListenersForMediaControls(); this.mediaControls.addListener('qualitychange', this.handleQuality); this.mediaControls.addListener('subtitlechange', this.handleSubtitle); + this.mediaControls.addListener('audiochange', this.handleAudioTrack); } /** @@ -345,6 +360,38 @@ class DashViewer extends VideoBaseViewer { ); } } + + /** + * Loads alternate audio streams + * + * @return {void} + */ + loadAlternateAudio() { + const variants = this.player.getVariantTracks().sort((track1, track2) => track1.audioId - track2.audioId); + const audioIds = []; + const uniqueAudioVariants = []; + + let i = 0; + for (i = 0; i < variants.length; i++) { + const audioTrack = variants[i]; + if (audioIds.indexOf(audioTrack.audioId) < 0) { + audioIds.push(audioTrack.audioId); + uniqueAudioVariants.push(audioTrack); + } + } + + this.audioTracks = uniqueAudioVariants.map((track) => ({ + 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); + } + } + /** * Handler for meta data load for the media element. * @@ -365,6 +412,7 @@ class DashViewer extends VideoBaseViewer { this.startBandwidthTracking(); this.handleQuality(); // should come after gettings rep ids this.loadSubtitles(); + this.loadAlternateAudio(); this.showPlayButton(); this.loaded = true; @@ -402,14 +450,24 @@ class DashViewer extends VideoBaseViewer { // Iterate over all available video representations and find the one that // seems the biggest so that the video player is set to the max size - const hdRepresentation = tracks.reduce((a, b) => (a.width > b.width ? a : b)); - const sdRepresentation = tracks.reduce((a, b) => (a.width < b.width ? a : b)); + const hdRep = tracks.reduce((a, b) => (a.width > b.width ? a : b)); + const sdRep = tracks.reduce((a, b) => (a.width < b.width ? a : b)); + if (this.player.isAudioOnly()) { + // There is only audio, no video + this.videoWidth = DEFAULT_VIDEO_WIDTH_PX; + this.videoHeight = DEFAULT_VIDEO_HEIGHT_PX; + } else { + this.videoWidth = hdRep.width; + this.videoHeight = hdRep.height; + this.sdVideoId = sdRep.videoId; + + // If there is an HD representation separate from the SD + if (hdRep.videoId !== sdRep.videoId) { + this.hdVideoId = hdRep.videoId; + } + } - this.videoWidth = hdRepresentation.width; - this.videoHeight = hdRepresentation.height; this.aspect = this.videoWidth / this.videoHeight; - this.hdRepresentation = hdRepresentation; - this.sdRepresentation = sdRepresentation; } /** diff --git a/src/lib/viewers/media/MediaControls.js b/src/lib/viewers/media/MediaControls.js index 1ac15006c..69029ba1a 100644 --- a/src/lib/viewers/media/MediaControls.js +++ b/src/lib/viewers/media/MediaControls.js @@ -179,6 +179,16 @@ class MediaControls extends EventEmitter { this.emit('subtitlechange'); } + /** + * Audio-track handler + * + * @private + * @return {void} + */ + handleAudioTrack() { + this.emit('audiochange'); + } + /** * Attaches settings menu * @@ -877,6 +887,17 @@ class MediaControls extends EventEmitter { this.settings.addListener('subtitles', this.handleSubtitle); this.settings.loadSubtitles(subtitles, language); } + + /** + * Takes a list of audio tracks and populates the settings menu + * + * @param {Array} audioLanguages - An ordered list of languages of the audio tracks + * @return {void} + */ + initAlternateAudio(audioLanguages) { + this.settings.addListener('audiotracks', this.handleAudioTrack); + this.settings.loadAlternateAudio(audioLanguages); + } } export default MediaControls; diff --git a/src/lib/viewers/media/Settings.js b/src/lib/viewers/media/Settings.js index 81bd3afa0..16204fc92 100644 --- a/src/lib/viewers/media/Settings.js +++ b/src/lib/viewers/media/Settings.js @@ -10,6 +10,7 @@ const CLASS_SETTINGS = 'bp-media-settings'; const CLASS_SETTINGS_SELECTED = 'bp-media-settings-selected'; const CLASS_SETTINGS_OPEN = 'bp-media-settings-is-open'; const CLASS_SETTINGS_SUBTITLES_UNAVAILABLE = 'bp-media-settings-subtitles-unavailable'; +const CLASS_SETTINGS_AUDIOTRACKS_UNAVAILABLE = 'bp-media-settings-audiotracks-unavailable'; const CLASS_SETTINGS_SUBTITLES_ON = 'bp-media-settings-subtitles-on'; const SELECTOR_SETTINGS_SUB_ITEM = '.bp-media-settings-sub-item'; const SELECTOR_SETTINGS_VALUE = '.bp-media-settings-value'; @@ -32,6 +33,11 @@ const SETTINGS_TEMPLATE = `
${__('off')}
${ICON_ARROW_RIGHT}
+ `; -const SUBTITLES_SUBITEM_TEMPLATE = `
+const SUBMENU_SUBITEM_TEMPLATE = `
${ICON_CHECK_MARK}
`; @@ -147,6 +159,7 @@ class Settings extends EventEmitter { addActivationListener(this.settingsEl, this.menuEventHandler); this.containerEl.classList.add(CLASS_SETTINGS_SUBTITLES_UNAVAILABLE); + this.containerEl.classList.add(CLASS_SETTINGS_AUDIOTRACKS_UNAVAILABLE); this.init(); } @@ -319,7 +332,8 @@ class Settings extends EventEmitter { } else if (event.type === 'keydown') { const key = decodeKeydown(event).toLowerCase(); const menuEl = menuItem.parentElement; - const itemIdx = [].findIndex.call(menuEl.children, (e) => { + const visibleOptions = [].filter.call(menuEl.children, (option) => option.offsetParent !== null); + const itemIdx = [].findIndex.call(visibleOptions, (e) => { return e.contains(menuItem); }); @@ -333,21 +347,21 @@ class Settings extends EventEmitter { case 'arrowup': { this.containerEl.classList.add(CLASS_ELEM_KEYBOARD_FOCUS); if (itemIdx > 0) { - const newNode = menuEl.children[itemIdx - 1]; + const newNode = visibleOptions[itemIdx - 1]; newNode.focus(); } break; } case 'arrowdown': { this.containerEl.classList.add(CLASS_ELEM_KEYBOARD_FOCUS); - if (itemIdx >= 0 && itemIdx < menuEl.children.length - 1) { - const newNode = menuEl.children[itemIdx + 1]; + if (itemIdx >= 0 && itemIdx < visibleOptions.length - 1) { + const newNode = visibleOptions[itemIdx + 1]; newNode.focus(); } break; } case 'arrowleft': { - if (itemIdx >= 0 && !menuEl.children[itemIdx].classList.contains('bp-media-settings-item')) { + if (itemIdx >= 0 && !visibleOptions[itemIdx].classList.contains('bp-media-settings-item')) { // Go back to the main menu this.reset(); this.firstMenuItem.focus(); @@ -356,7 +370,7 @@ class Settings extends EventEmitter { } case 'arrowright': { if (itemIdx >= 0) { - const curNode = menuEl.children[itemIdx]; + const curNode = visibleOptions[itemIdx]; const dataType = curNode.getAttribute('data-type'); if (curNode.classList.contains('bp-media-settings-item') && dataType !== 'menu') { this.showSubMenu(dataType); @@ -422,8 +436,11 @@ class Settings extends EventEmitter { // Remove the checkmark from the prior selected option in the sub menu const prevSelected = this.getSelectedOption(type); - prevSelected.classList.remove(CLASS_SETTINGS_SELECTED); - prevSelected.removeAttribute('aria-checked'); + if (prevSelected) { + // this may not exist, for instance, when first initializing audio tracks + prevSelected.classList.remove(CLASS_SETTINGS_SELECTED); + prevSelected.removeAttribute('aria-checked'); + } // Add a checkmark to the new selected option in the sub menu option.classList.add(CLASS_SETTINGS_SELECTED); @@ -588,7 +605,10 @@ class Settings extends EventEmitter { this.subtitles = subtitles; this.language = language; this.subtitles.forEach((subtitle, idx) => { - insertTemplate(subtitlesSubMenu, SUBTITLES_SUBITEM_TEMPLATE.replace(/{{dataValue}}/g, idx)); + insertTemplate( + subtitlesSubMenu, + SUBMENU_SUBITEM_TEMPLATE.replace(/{{dataType}}/g, 'subtitles').replace(/{{dataValue}}/g, idx) + ); const languageNode = subtitlesSubMenu.lastChild.querySelector('.bp-media-settings-value'); languageNode.textContent = subtitle; }); @@ -602,6 +622,34 @@ class Settings extends EventEmitter { this.reset(); } + + /** + * Takes an ordered list of audio languages and populates the settings menu + * + * @param {Array} audioLanguages - An ordered list of languages of the audio tracks + * @return {void} + */ + loadAlternateAudio(audioLanguages) { + const audioTracksSubMenu = this.settingsEl.querySelector('.bp-media-settings-menu-audiotracks'); + audioLanguages.forEach((language, idx) => { + insertTemplate( + audioTracksSubMenu, + SUBMENU_SUBITEM_TEMPLATE.replace(/{{dataType}}/g, 'audiotracks').replace(/{{dataValue}}/g, idx) + ); + const trackNode = audioTracksSubMenu.lastChild.querySelector('.bp-media-settings-value'); + // It's common for the language to be unknown and show up as "und" language code. Just omit + // the language in this case, otherwise display the language too + let textContent = `${__('track')} ${idx + 1}`; + if (language !== 'und') { + textContent = `${textContent} (${language})`; + } + trackNode.textContent = textContent; + }); + this.chooseOption('audiotracks', '0'); + + this.containerEl.classList.remove(CLASS_SETTINGS_AUDIOTRACKS_UNAVAILABLE); + this.reset(); + } } export default Settings; diff --git a/src/lib/viewers/media/Settings.scss b/src/lib/viewers/media/Settings.scss index e813e3fa3..c459c60f9 100644 --- a/src/lib/viewers/media/Settings.scss +++ b/src/lib/viewers/media/Settings.scss @@ -39,15 +39,18 @@ $item-hover-color: #f6fafd; .bp-media-settings-menu-quality, .bp-media-settings-menu-speed, .bp-media-settings-menu-subtitles, +.bp-media-settings-menu-audiotracks, .bp-media-settings-show-speed .bp-media-settings-menu-main, .bp-media-settings-show-quality .bp-media-settings-menu-main, -.bp-media-settings-show-subtitles .bp-media-settings-menu-main { +.bp-media-settings-show-subtitles .bp-media-settings-menu-main, +.bp-media-settings-show-audiotracks .bp-media-settings-menu-main { display: none; } .bp-media-settings-show-speed .bp-media-settings-menu-speed, .bp-media-settings-show-quality .bp-media-settings-menu-quality, -.bp-media-settings-show-subtitles .bp-media-settings-menu-subtitles { +.bp-media-settings-show-subtitles .bp-media-settings-menu-subtitles, +.bp-media-settings-show-audiotracks .bp-media-settings-menu-audiotracks { display: table; } @@ -55,7 +58,8 @@ $item-hover-color: #f6fafd; .bp-media-mp4, .bp-media-mp3 { .bp-media-settings-item-quality, - .bp-media-settings-item-subtitles { + .bp-media-settings-item-subtitles, + .bp-media-settings-item-audiotracks { display: none; } } @@ -103,6 +107,13 @@ $item-hover-color: #f6fafd; } } +.bp-media-settings-item-audiotracks, +.bp-media-settings-menu-audiotracks { + .bp-media-settings-audiotracks-unavailable & { + display: none; + } +} + .bp-media-settings-label, .bp-media-settings-value { display: table-cell; diff --git a/src/lib/viewers/media/__tests__/DashViewer-test.js b/src/lib/viewers/media/__tests__/DashViewer-test.js index 4def90693..f751198a1 100644 --- a/src/lib/viewers/media/__tests__/DashViewer-test.js +++ b/src/lib/viewers/media/__tests__/DashViewer-test.js @@ -65,9 +65,11 @@ describe('lib/viewers/media/DashViewer', () => { getStats: () => {}, getTextTracks: () => {}, getVariantTracks: () => {}, + isAudioOnly: () => {}, load: () => {}, selectTextTrack: () => {}, selectVariantTrack: () => {}, + selectAudioLanguage: () => {}, setTextTrackVisibility: () => {} }; stubs.mockPlayer = sandbox.mock(dash.player); @@ -77,6 +79,7 @@ describe('lib/viewers/media/DashViewer', () => { destroy: () => {}, initFilmstrip: () => {}, initSubtitles: () => {}, + initAlternateAudio: () => {}, removeAllListeners: () => {}, removeListener: () => {}, show: sandbox.stub() @@ -105,8 +108,8 @@ describe('lib/viewers/media/DashViewer', () => { it('should set up dash element', () => { expect(dash.bandwidthHistory).to.deep.equal([]); expect(dash.switchHistory).to.deep.equal([]); - expect(dash.hdRepresentation).to.deep.equal({}); - expect(dash.sdRepresentation).to.deep.equal({}); + expect(dash.hdVideoId).to.equal(-1); + expect(dash.sdVideoId).to.equal(-1); expect(dash.wrapperEl).to.have.class(CSS_CLASS_MEDIA); }); }); @@ -241,23 +244,54 @@ describe('lib/viewers/media/DashViewer', () => { }); }); - describe('enableHD()', () => { - it('should enable HD video for the file', () => { - dash.hdRepresentation = { id: '1' }; + describe('enableVideoId()', () => { + it('should enable videoId while maintaining the same audio', () => { + const variant1 = { id: 1, videoId: 1, audioId: 5, active: false }; + const variant2 = { id: 2, videoId: 2, audioId: 5, active: false }; + const variant3 = { id: 3, videoId: 1, audioId: 6, active: false }; + const variant4 = { id: 4, videoId: 2, audioId: 6, active: true }; + const variant5 = { id: 5, videoId: 1, audioId: 7, active: false }; + const variant6 = { id: 6, videoId: 2, audioId: 7, active: false }; + stubs.mockPlayer.expects('getVariantTracks').returns([ + variant1, variant2, variant3, variant4, variant5, variant6 + ]); + sandbox.stub(dash, 'getActiveTrack').returns(variant4); sandbox.stub(dash, 'showLoadingIcon'); - stubs.mockPlayer.expects('selectVariantTrack').withArgs(dash.hdRepresentation, true); - dash.enableHD(); - expect(dash.showLoadingIcon).to.be.calledWith('1'); + stubs.mockPlayer.expects('selectVariantTrack').withArgs(variant3, true); + + dash.enableVideoId(1); + + expect(dash.showLoadingIcon).to.be.calledWith(3); }); - }); - describe('enableSD()', () => { - it('should enable SD video for the file', () => { - dash.sdRepresentation = { id: '1' }; + it('should do nothing if enabling a videoId which is already active', () => { + const variant1 = { id: 1, videoId: 1, audioId: 5, active: false }; + const variant2 = { id: 2, videoId: 2, audioId: 5, active: true }; + stubs.mockPlayer.expects('getVariantTracks').returns([ + variant1, variant2 + ]); + sandbox.stub(dash, 'getActiveTrack').returns(variant2); sandbox.stub(dash, 'showLoadingIcon'); - stubs.mockPlayer.expects('selectVariantTrack').withArgs(dash.sdRepresentation, true); - dash.enableSD(); - expect(dash.showLoadingIcon).to.be.calledWith('1'); + stubs.mockPlayer.expects('selectVariantTrack').never(); + + dash.enableVideoId(2); + + expect(dash.showLoadingIcon).to.not.be.called; + }); + + it('should do nothing if enabling an invalid videoId', () => { + const variant1 = { id: 1, videoId: 1, audioId: 5, active: false }; + const variant2 = { id: 2, videoId: 2, audioId: 5, active: true }; + stubs.mockPlayer.expects('getVariantTracks').returns([ + variant1, variant2 + ]); + sandbox.stub(dash, 'getActiveTrack').returns(variant2); + sandbox.stub(dash, 'showLoadingIcon'); + stubs.mockPlayer.expects('selectVariantTrack').never(); + + dash.enableVideoId(-1); + + expect(dash.showLoadingIcon).to.not.be.called; }); }); @@ -275,8 +309,9 @@ describe('lib/viewers/media/DashViewer', () => { describe('handleQuality()', () => { beforeEach(() => { - stubs.hd = sandbox.stub(dash, 'enableHD'); - stubs.sd = sandbox.stub(dash, 'enableSD'); + dash.hdVideoId = 1; + dash.sdVideoId = 2; + stubs.enableVideoId = sandbox.stub(dash, 'enableVideoId'); stubs.adapt = sandbox.stub(dash, 'enableAdaptation'); }); @@ -284,7 +319,7 @@ describe('lib/viewers/media/DashViewer', () => { sandbox.stub(dash.cache, 'get').returns('hd'); dash.handleQuality(); expect(stubs.adapt).to.be.calledWith(false); - expect(stubs.hd).to.be.called; + expect(stubs.enableVideoId).to.be.calledWith(dash.hdVideoId); expect(dash.emit).to.be.calledWith('qualitychange', 'hd'); }); @@ -292,7 +327,7 @@ describe('lib/viewers/media/DashViewer', () => { sandbox.stub(dash.cache, 'get').returns('sd'); dash.handleQuality(); expect(stubs.adapt).to.be.calledWith(false); - expect(stubs.sd).to.be.called; + expect(stubs.enableVideoId).to.be.calledWith(dash.sdVideoId); expect(dash.emit).to.be.calledWith('qualitychange', 'sd'); }); @@ -313,11 +348,11 @@ describe('lib/viewers/media/DashViewer', () => { describe('adaptationHandler()', () => { beforeEach(() => { - stubs.active = { id: 1, bandwidth: 'bandwidth' }; + stubs.active = { id: 1, bandwidth: 'bandwidth', videoId: 1 }; stubs.getActive = sandbox.stub(dash, 'getActiveTrack').returns(stubs.active); stubs.loaded = sandbox.stub(dash, 'isLoaded').returns(true); stubs.hide = sandbox.stub(dash, 'hideLoadingIcon'); - dash.hdRepresentation = { id: 1 }; + dash.hdVideoId = 1; dash.adapting = false; }); @@ -328,7 +363,7 @@ describe('lib/viewers/media/DashViewer', () => { }); it('should handle change from HD resolution', () => { - stubs.getActive.returns({ id: 2 }); + stubs.getActive.returns({ id: 2, videoId: 2 }); dash.wrapperEl.classList.add(CSS_CLASS_HD); dash.adaptationHandler(); expect(dash.wrapperEl).to.not.have.class(CSS_CLASS_HD); @@ -401,6 +436,7 @@ describe('lib/viewers/media/DashViewer', () => { }); stubs.mockControls.expects('addListener').withArgs('qualitychange', sinon.match.func); stubs.mockControls.expects('addListener').withArgs('subtitlechange', sinon.match.func); + stubs.mockControls.expects('addListener').withArgs('audiochange', sinon.match.func); dash.addEventListenersForMediaControls(); }); }); @@ -419,6 +455,7 @@ describe('lib/viewers/media/DashViewer', () => { sandbox.stub(dash, 'calculateVideoDimensions'); sandbox.stub(dash, 'loadUI'); sandbox.stub(dash, 'loadFilmStrip'); + sandbox.stub(dash, 'loadAlternateAudio'); sandbox.stub(dash, 'resize'); sandbox.stub(dash, 'handleVolume'); sandbox.stub(dash, 'startBandwidthTracking'); @@ -430,6 +467,7 @@ describe('lib/viewers/media/DashViewer', () => { expect(dash.showMedia).to.be.called; expect(dash.showPlayButton).to.be.called; expect(dash.loadSubtitles).to.be.called; + expect(dash.loadAlternateAudio).to.be.called; expect(dash.emit).to.be.calledWith('load'); expect(dash.loaded).to.be.true; expect(document.activeElement).to.equal(dash.mediaContainerEl); @@ -558,6 +596,55 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('loadAlternateAudio()', () => { + it('should select unique audio tracks', () => { + const variant1 = { videoId: 0, audioId: 0, language: 'eng', roles: ['audio0']}; + const variant2 = { videoId: 1, audioId: 0, language: 'eng', roles: ['audio0']}; + const variant3 = { videoId: 0, audioId: 1, language: 'rus', roles: ['audio1']}; + const variant4 = { videoId: 1, audioId: 1, language: 'rus', roles: ['audio1']}; + const variant5 = { videoId: 2, audioId: 1, language: 'rus', roles: ['audio1']}; + const allVariants = [variant1, variant2, variant3, variant4, variant5]; + stubs.mockPlayer.expects('getVariantTracks').returns(allVariants); + stubs.mockControls.expects('initAlternateAudio'); + + dash.loadAlternateAudio(); + + expect(dash.audioTracks).to.deep.equal([ + { language: 'eng', role: 'audio0' }, + { language: 'rus', role: 'audio1' } + ]); + }); + + it('should translate and initialize audio in sorted order', () => { + const variant1 = { videoId: 0, audioId: 0, language: 'eng', roles: ['audio0']}; + const variant2 = { videoId: 0, audioId: 1, language: 'rus', roles: ['audio0']}; + const variant3 = { videoId: 0, audioId: 2, language: 'spa', roles: ['audio0']}; + const variant4 = { videoId: 0, audioId: 3, language: 'kor', roles: ['audio0']}; + const variant5 = { videoId: 0, audioId: 4, language: 'fra', roles: ['audio0']}; + const allVariants = [variant3, variant1, variant4, variant2, variant5]; + stubs.mockPlayer.expects('getVariantTracks').returns(allVariants); + stubs.mockControls + .expects('initAlternateAudio') + .withArgs(['English', 'Russian', 'Spanish', 'Korean', 'French']); + + dash.loadAlternateAudio(); + }); + + it('should not initialize alternate audio if there is none', () => { + const variant1 = { videoId: 0, audioId: 0, language: 'eng', roles: ['audio0']}; + const variant2 = { videoId: 1, audioId: 0, language: 'eng', roles: ['audio0']}; + const allVariants = [variant1, variant2]; + stubs.mockPlayer.expects('getVariantTracks').returns(allVariants); + stubs.mockControls.expects('initAlternateAudio').never(); + + dash.loadAlternateAudio(); + + expect(dash.audioTracks).to.deep.equal([ + { language: 'eng', role: 'audio0' } + ]); + }); + }); + describe('handleSubtitle()', () => { it('should select track from front of text track list', () => { const english = { language: 'eng', id: 3 }; @@ -620,12 +707,67 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('handleAudioTrack()', () => { + it('should select correct audio', () => { + dash.audioTracks = [ + { language: 'eng', role: 'audio0' }, + { language: 'eng', role: 'audio1' }, + { language: 'eng', role: 'audio2' } + ]; + sandbox.stub(dash.cache, 'get').returns('1'); + stubs.mockPlayer.expects('selectAudioLanguage').withArgs('eng', 'audio1'); + + dash.handleAudioTrack(); + }); + + it('should not select audio if index out of bounds', () => { + dash.audioTracks = [ + { language: 'eng', role: 'audio0' }, + { language: 'eng', role: 'audio1' }, + { language: 'eng', role: 'audio2' } + ]; + sandbox.stub(dash.cache, 'get').returns('3'); + stubs.mockPlayer.expects('selectAudioLanguage').never(); + + dash.handleAudioTrack(); + }); + }); + describe('calculateVideoDimensions()', () => { it('should calculate the video dimensions based on the reps', () => { - stubs.mockPlayer.expects('getVariantTracks').returns([{ width: 200 }, { width: 100 }]); + stubs.mockPlayer.expects('isAudioOnly').returns(false); + stubs.mockPlayer.expects('getVariantTracks').returns([ + { width: 200, videoId: 1 }, + { width: 100, videoId: 2 } + ]); + dash.calculateVideoDimensions(); + expect(dash.hdVideoId).to.equal(1); + expect(dash.sdVideoId).to.equal(2); + expect(dash.videoWidth).to.equal(200); + }); + + it('should use SD video dimensions if no HD', () => { + stubs.mockPlayer.expects('isAudioOnly').returns(false); + stubs.mockPlayer.expects('getVariantTracks').returns([ + { width: 640, videoId: 1, audioId: 2 }, + { width: 640, videoId: 1, audioId: 3 } + ]); + dash.calculateVideoDimensions(); + expect(dash.hdVideoId).to.equal(-1); + expect(dash.sdVideoId).to.equal(1); + expect(dash.videoWidth).to.equal(640); + }); + + it('should default video dimensions when video is audio-only', () => { + stubs.mockPlayer.expects('isAudioOnly').returns(true); + stubs.mockPlayer.expects('getVariantTracks').returns([ + { width: null, videoId: null, audioId: 1 }, + { width: null, videoId: null, audioId: 2 } + ]); dash.calculateVideoDimensions(); - expect(dash.hdRepresentation.width).to.equal(200); - expect(dash.sdRepresentation.width).to.equal(100); + expect(dash.hdVideoId).to.equal(-1); + expect(dash.sdVideoId).to.equal(-1); + expect(dash.videoWidth).to.equal(854); // default to width of 854 (480p) }); }); diff --git a/src/lib/viewers/media/__tests__/MediaControls-test.js b/src/lib/viewers/media/__tests__/MediaControls-test.js index 95d3183e3..6e883203b 100644 --- a/src/lib/viewers/media/__tests__/MediaControls-test.js +++ b/src/lib/viewers/media/__tests__/MediaControls-test.js @@ -1021,5 +1021,17 @@ describe('lib/viewers/media/MediaControls', () => { expect(mediaControls.settings.loadSubtitles).to.be.calledWith(subs); }); }); + + describe('initAlternateAudio()', () => { + it('should load alternate audio', () => { + sandbox.stub(mediaControls.settings, 'loadAlternateAudio'); + const audios = [ + { language: 'eng', role: 'audio0' }, + { language: 'rus', role: 'audio1' } + ]; + mediaControls.initAlternateAudio(audios); + expect(mediaControls.settings.loadAlternateAudio).to.be.calledWith(audios); + }); + }); }); /* eslint-enable no-unused-expressions */ diff --git a/src/lib/viewers/media/__tests__/Settings-test.js b/src/lib/viewers/media/__tests__/Settings-test.js index 9da27a0f4..023c5bda5 100644 --- a/src/lib/viewers/media/__tests__/Settings-test.js +++ b/src/lib/viewers/media/__tests__/Settings-test.js @@ -765,6 +765,51 @@ describe('lib/viewers/media/Settings', () => { }); }); + describe('loadAlternateAudio()', () => { + it('Should load all audio tracks and make them available', () => { + const audioMenu = settings.settingsEl.querySelector('.bp-media-settings-menu-audiotracks'); + sandbox.stub(settings, 'chooseOption'); + + settings.loadAlternateAudio(['English', 'Russian', 'Spanish']); + + expect(settings.chooseOption).to.be.calledWith('audiotracks', '0'); + expect(audioMenu.children.length).to.equal(4); // Three languages, and back to main menu + expect(settings.containerEl).to.not.have.class('bp-media-settings-audiotracks-unavailable'); + }); + + it('Should reset menu dimensions after loading', () => { + sandbox.stub(settings, 'setMenuContainerDimensions'); + + settings.loadAlternateAudio(['English', 'Russian', 'Spanish']); + + expect(settings.setMenuContainerDimensions).to.be.calledWith(settings.settingsEl.firstChild); + }); + + it('Should not list language for "und" language code', () => { + const audioMenu = settings.settingsEl.querySelector('.bp-media-settings-menu-audiotracks'); + + settings.loadAlternateAudio(['English', 'und']); + + const audio0 = audioMenu.querySelector('[data-value="0"]').querySelector('.bp-media-settings-value'); + const audio1 = audioMenu.querySelector('[data-value="1"]').querySelector('.bp-media-settings-value'); + expect(audio0.innerHTML).to.equal('Track 1 (English)'); + expect(audio1.innerHTML).to.equal('Track 2'); + }); + + it('Should escape audio languages and roles', () => { + const audioMenu = settings.settingsEl.querySelector('.bp-media-settings-menu-audiotracks'); + + // There shouldn't be a way to get such inputs into this method in normal use case anyway + // because it goes through multiple levels of sanitization, but just in case... + settings.loadAlternateAudio(['English', '']); + + const audio0 = audioMenu.querySelector('[data-value="0"]').querySelector('.bp-media-settings-value'); + const audio1 = audioMenu.querySelector('[data-value="1"]').querySelector('.bp-media-settings-value'); + expect(audio0.innerHTML).to.equal('Track 1 (English)'); + expect(audio1.innerHTML).to.equal('Track 2 (<badboy>)'); + }); + }); + describe('hasSubtitles()', () => { it('Should be false before loading subtitles', () => { expect(settings.hasSubtitles()).to.be.false;