diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index c75f188d0..73b44ed6c 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -76,10 +76,16 @@ media_play=Play media_pause=Pause # Label for Settings button in media player media_settings=Settings +# Label for subtitles/closed-captions in media player +media_subtitles_cc=Subtitles/Closed Captions # Used in ARIA label for volume volume=Volume # Used in ARIA label for timescrubber progress of=of +# Label for subtitles in media player +subtitles=Subtitles +# Label for turning off subtitles in media player +off=Off # 3D Preview diff --git a/src/lib/viewers/media/DashViewer.js b/src/lib/viewers/media/DashViewer.js index e013e2efb..41ad4d018 100644 --- a/src/lib/viewers/media/DashViewer.js +++ b/src/lib/viewers/media/DashViewer.js @@ -29,6 +29,7 @@ class DashViewer extends VideoBaseViewer { // tracks this.hdRepresentation = {}; this.sdRepresentation = {}; + this.textTracks = []; // Must be sorted by representation id // dash specific class this.wrapperEl.classList.add(CSS_CLASS_DASH); @@ -59,6 +60,7 @@ class DashViewer extends VideoBaseViewer { } if (this.mediaControls) { this.mediaControls.removeListener('qualitychange', this.handleQuality); + this.mediaControls.removeListener('subtitlechange', this.handleSubtitle); } this.removeStats(); super.destroy(); @@ -217,6 +219,26 @@ class DashViewer extends VideoBaseViewer { this.player.configure({ abr: { enabled: adapt } }); } + /** + * Handler for subtitle + * + * @private + * @emits subtitlechange + * @return {void} + */ + handleSubtitle() { + const subtitleIdx = parseInt(cache.get('media-subtitles'), 10); + if (this.textTracks[subtitleIdx] !== undefined) { + const track = this.textTracks[subtitleIdx]; + this.player.selectTextTrack(track); + this.player.setTextTrackVisibility(true); + this.emit('subtitlechange', track.language); + } else { + this.player.setTextTrackVisibility(false); + this.emit('subtitlechange', null); + } + } + /** * Handler for hd/sd/auto video * @@ -279,8 +301,20 @@ class DashViewer extends VideoBaseViewer { addEventListenersForMediaControls() { super.addEventListenersForMediaControls(); this.mediaControls.addListener('qualitychange', this.handleQuality); + this.mediaControls.addListener('subtitlechange', this.handleSubtitle); } + /** + * Loads captions/subtitles into the settings menu + * + * @return {void} + */ + loadSubtitles() { + this.textTracks = this.player.getTextTracks().sort((track1, track2) => track1.id - track2.id); + if (this.textTracks.length > 0) { + this.mediaControls.initSubtitles(this.textTracks.map((track) => track.language)); + } + } /** * Handler for meta data load for the media element. * @@ -300,6 +334,7 @@ class DashViewer extends VideoBaseViewer { this.handleVolume(); this.startBandwidthTracking(); this.handleQuality(); // should come after gettings rep ids + this.loadSubtitles(); this.showPlayButton(); this.loaded = true; diff --git a/src/lib/viewers/media/MediaBaseViewer.js b/src/lib/viewers/media/MediaBaseViewer.js index b5c0f8dc8..8720e1669 100644 --- a/src/lib/viewers/media/MediaBaseViewer.js +++ b/src/lib/viewers/media/MediaBaseViewer.js @@ -561,6 +561,10 @@ class MediaBaseViewer extends BaseViewer { case 'shift+m': this.toggleMute(); break; + case 'c': + case 'shift+c': + this.mediaControls.toggleSubtitles(); + break; default: return false; } diff --git a/src/lib/viewers/media/MediaControls.html b/src/lib/viewers/media/MediaControls.html index d89b00480..52ddc1089 100644 --- a/src/lib/viewers/media/MediaControls.html +++ b/src/lib/viewers/media/MediaControls.html @@ -35,6 +35,10 @@ 00:00 / 00:00 + + CC + + diff --git a/src/lib/viewers/media/MediaControls.js b/src/lib/viewers/media/MediaControls.js index aaa129801..5766f3865 100644 --- a/src/lib/viewers/media/MediaControls.js +++ b/src/lib/viewers/media/MediaControls.js @@ -59,6 +59,9 @@ class MediaControls extends EventEmitter { this.settingsButtonEl = this.wrapperEl.querySelector('.bp-media-gear-icon'); this.setLabel(this.settingsButtonEl, __('media_settings')); + this.subtitlesButtonEl = this.wrapperEl.querySelector('.bp-media-cc-icon'); + this.setLabel(this.subtitlesButtonEl, __('media_subtitles_cc')); + this.setDuration(this.mediaEl.duration); this.setupSettings(); this.setupScrubbers(); @@ -96,6 +99,7 @@ class MediaControls extends EventEmitter { if (this.settings) { this.settings.removeListener('quality', this.handleQuality); this.settings.removeListener('speed', this.handleRate); + this.settings.removeListener('subtitles', this.handleSubtitle); this.settings.destroy(); this.settings = undefined; } @@ -116,6 +120,10 @@ class MediaControls extends EventEmitter { removeActivationListener(this.settingsButtonEl, this.toggleSettingsHandler); } + if (this.subtitlesButtonEl) { + removeActivationListener(this.subtitlesButtonEl, this.toggleSubtitlesHandler); + } + if (this.wrapperEl) { this.wrapperEl.removeEventListener('mouseenter', this.mouseenterHandler); this.wrapperEl.removeEventListener('mouseleave', this.mouseleaveHandler); @@ -159,6 +167,16 @@ class MediaControls extends EventEmitter { this.emit('qualitychange'); } + /** + * Subtitle handler + * + * @private + * @return {void} + */ + handleSubtitle() { + this.emit('subtitlechange'); + } + /** * Attaches settings menu * @@ -318,6 +336,17 @@ class MediaControls extends EventEmitter { } } + /** + * Toggles subtitles + * + * @return {void} + */ + toggleSubtitles() { + this.show(); + this.settings.toggleSubtitles(); + this.emit('togglesubtitles'); + } + /** * Toggles label for control element with more than one state * @return {void} @@ -496,11 +525,13 @@ class MediaControls extends EventEmitter { this.toggleMuteHandler = activationHandler(this.toggleMute); this.toggleFullscreenHandler = activationHandler(this.toggleFullscreen); this.toggleSettingsHandler = activationHandler(this.toggleSettings); + this.toggleSubtitlesHandler = activationHandler(this.toggleSubtitles); addActivationListener(this.playButtonEl, this.togglePlayHandler); addActivationListener(this.volButtonEl, this.toggleMuteHandler); addActivationListener(this.fullscreenButtonEl, this.toggleFullscreenHandler); addActivationListener(this.settingsButtonEl, this.toggleSettingsHandler); + addActivationListener(this.subtitlesButtonEl, this.toggleSubtitlesHandler); fullscreen.addListener('exit', this.setFullscreenLabel); } @@ -769,6 +800,17 @@ class MediaControls extends EventEmitter { isVolumeScrubberFocused() { return document.activeElement === this.volScrubberEl; } + + /** + * Takes a list of subtitle names and populates the settings menu + * + * @param {Array} subtitles - A list of subtitle names as strings + * @return {void} + */ + initSubtitles(subtitles) { + this.settings.addListener('subtitles', this.handleSubtitle); + this.settings.loadSubtitles(subtitles); + } } export default MediaControls; diff --git a/src/lib/viewers/media/MediaControls.scss b/src/lib/viewers/media/MediaControls.scss index 132bde551..670af1c12 100644 --- a/src/lib/viewers/media/MediaControls.scss +++ b/src/lib/viewers/media/MediaControls.scss @@ -148,6 +148,47 @@ right: 55px; } +.bp-media-cc-icon { + position: absolute; + right: 100px; + visibility: visible; + + .bp-media-settings-subtitles-unavailable & { + visibility: hidden; + } +} + +.bp-media-cc-icon-on { + background-color: $white; + bottom: 6px; + display: inline-block; + height: 3px; + left: 12px; + opacity: 0; + position: absolute; + transition: opacity .3s; + width: 21px; +} + +.bp-media-controls-cc-icon-text { + background: $white; + border-radius: 3px; + bottom: 2px; + color: $black; + font-size: 11px; + font-weight: 600; + letter-spacing: .1em; + line-height: 8px; + padding: 2px 2px 2px 3px; + position: relative; +} + +.bp-media-settings-subtitles-on { + .bp-media-cc-icon-on { + opacity: 1; + } +} + .bp-media-controls-hd { background: $box-blue; border-radius: 3px; diff --git a/src/lib/viewers/media/Settings.js b/src/lib/viewers/media/Settings.js index e65fdd18c..355104510 100644 --- a/src/lib/viewers/media/Settings.js +++ b/src/lib/viewers/media/Settings.js @@ -8,6 +8,8 @@ import { CLASS_ELEM_KEYBOARD_FOCUS } from '../../constants'; 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_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'; const MEDIA_SPEEDS = [ @@ -31,6 +33,11 @@ const SETTINGS_TEMPLATE = ` ${__('media_quality_auto')} ${ICON_ARROW_RIGHT} + + ${__('subtitles')}/CC + ${__('off')} + ${ICON_ARROW_RIGHT} + @@ -80,6 +87,21 @@ const SETTINGS_TEMPLATE = ` ${__('media_quality_auto')} + + + ${ICON_ARROW_LEFT} + ${__('subtitles')}/CC + + + ${ICON_CHECK_MARK} + ${__('off')} + + +`; + +const SUBTITLES_SUBITEM_TEMPLATE = ` + ${ICON_CHECK_MARK} + `; @autobind @@ -104,11 +126,15 @@ class Settings extends EventEmitter { addActivationListener(this.settingsEl, this.menuEventHandler); this.visible = false; + this.hasSubtitles = false; + this.containerEl.classList.add(CLASS_SETTINGS_SUBTITLES_UNAVAILABLE); + this.toggleToSubtitle = 0; // An index into the subtitles track list. Initialize with the first subtitle in list this.init(); } /** - * Inits the menu + * Inits the menu. Note that we cannot initialize subtitles here because we don't know until we've loaded the video + * whether subtitles are available or not * * @return {void} */ @@ -309,12 +335,9 @@ class Settings extends EventEmitter { case 'arrowright': { if (itemIdx >= 0) { const curNode = menuEl.children[itemIdx]; - if (curNode.classList.contains('bp-media-settings-item')) { - if (curNode.getAttribute('data-type') === 'speed') { - this.showSubMenu('speed'); - } else if (curNode.getAttribute('data-type') === 'quality') { - this.showSubMenu('quality'); - } + const dataType = curNode.getAttribute('data-type'); + if (curNode.classList.contains('bp-media-settings-item') && dataType !== 'menu') { + this.showSubMenu(dataType); } } break; @@ -339,6 +362,17 @@ class Settings extends EventEmitter { } } + /** + * Returns the selected option in the submenu specified by `type` + * + * @private + * @param {string} type - The submenu (e.g. speed, quality, subtitles) + * @return {HTMLElement} The sub-item html element from the Settings menu that's selected + */ + getSelectedOption(type) { + return this.settingsEl.querySelector(`[data-type="${type}"]${SELECTOR_SETTINGS_SUB_ITEM}.${CLASS_SETTINGS_SELECTED}`); + } + /** * Handles option selection * @@ -363,7 +397,7 @@ class Settings extends EventEmitter { this.settingsEl.querySelector(`[data-type="${type}"] ${SELECTOR_SETTINGS_VALUE}`).textContent = label; // Remove the checkmark from the prior selected option in the sub menu - const prevSelected = this.settingsEl.querySelector(`[data-type="${type}"]${SELECTOR_SETTINGS_SUB_ITEM}.${CLASS_SETTINGS_SELECTED}`); + const prevSelected = this.getSelectedOption(type); prevSelected.classList.remove(CLASS_SETTINGS_SELECTED); prevSelected.removeAttribute('aria-checked'); @@ -374,6 +408,31 @@ class Settings extends EventEmitter { // Return to main menu this.reset(); this.firstMenuItem.focus(); + + if (type === 'subtitles') { + this.handleSubtitleSelection(prevSelected.getAttribute('data-value'), value); + } + } + + /** + * Helper function for special handling for subtitle selection. Needs to keep track + * of previous value (for toggling) and add CSS so that the CC button turns off + * + * @private + * @param {string} prevValue - This is a string but semantically a number (an index into the subtitle track list) + * @param {string} newValue - This is a string but semantically a number (an index into the subtitle track list) + * @return {void} + */ + handleSubtitleSelection(prevValue, newValue) { + if (newValue === '-1') { + if (prevValue !== newValue) { + this.toggleToSubtitle = prevValue; + } + + this.containerEl.classList.remove(CLASS_SETTINGS_SUBTITLES_ON); + } else { + this.containerEl.classList.add(CLASS_SETTINGS_SUBTITLES_ON); + } } /** @@ -439,6 +498,54 @@ class Settings extends EventEmitter { document.removeEventListener('click', this.blurHandler, true); document.removeEventListener('keydown', this.blurHandler, true); } + + /** + * Returns whether subtitles are on or not + * + * @private + * @return {boolean} + */ + areSubtitlesOn() { + const selected = this.getSelectedOption('subtitles'); + return selected.getAttribute('data-value') !== '-1'; + } + + /** + * Toggles subtitles on/off + * + * @return {void} + */ + toggleSubtitles() { + if (this.areSubtitlesOn()) { + this.chooseOption('subtitles', '-1'); + } else if (this.hasSubtitles) { + this.chooseOption('subtitles', this.toggleToSubtitle.toString()); + } + } + + /** + * Takes a list of subtitle names and populates the settings menu + * + * @param {Array} subtitles - A list of subtitle names as strings + * @return {void} + */ + loadSubtitles(subtitles) { + const subtitlesSubMenu = this.settingsEl.querySelector('.bp-media-settings-menu-subtitles'); + subtitles.forEach((subtitle, idx) => { + insertTemplate(subtitlesSubMenu, SUBTITLES_SUBITEM_TEMPLATE.replace(/{{dataValue}}/g, idx)); + const languageNode = subtitlesSubMenu.lastChild.querySelector('.bp-media-settings-value'); + languageNode.textContent = subtitle; + }); + + this.containerEl.classList.remove(CLASS_SETTINGS_SUBTITLES_UNAVAILABLE); + this.hasSubtitles = true; + const subsCache = cache.get('media-subtitles'); + if (subsCache !== null && subsCache !== '-1') { // Last video watched with subtitles, so turn them on here too + this.toggleSubtitles(); + } + + this.reset(); + } } export default Settings; diff --git a/src/lib/viewers/media/Settings.scss b/src/lib/viewers/media/Settings.scss index 6d4c8051b..9252f6cff 100644 --- a/src/lib/viewers/media/Settings.scss +++ b/src/lib/viewers/media/Settings.scss @@ -38,20 +38,24 @@ $item-hover-color: #f6fafd; .bp-media-settings-menu-quality, .bp-media-settings-menu-speed, +.bp-media-settings-menu-subtitles, .bp-media-settings-show-speed .bp-media-settings-menu-main, -.bp-media-settings-show-quality .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 { 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-quality .bp-media-settings-menu-quality, +.bp-media-settings-show-subtitles .bp-media-settings-menu-subtitles { display: table; } // For MP3 and MP4 we only have speed option .bp-media-mp4, .bp-media-mp3 { - .bp-media-settings-item-quality { + .bp-media-settings-item-quality, + .bp-media-settings-item-subtitles { display: none; } } @@ -83,6 +87,13 @@ $item-hover-color: #f6fafd; } } +.bp-media-settings-item-subtitles, +.bp-media-settings-menu-subtitles { + .bp-media-settings-subtitles-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 f1a9e6d15..d382dfebc 100644 --- a/src/lib/viewers/media/__tests__/DashViewer-test.js +++ b/src/lib/viewers/media/__tests__/DashViewer-test.js @@ -57,9 +57,12 @@ describe('lib/viewers/media/DashViewer', () => { destroy: () => {}, getNetworkingEngine: sandbox.stub().returns(stubs.networkEngine), getStats: () => {}, + getTextTracks: () => {}, getVariantTracks: () => {}, load: () => {}, - selectVariantTrack: () => {} + selectTextTrack: () => {}, + selectVariantTrack: () => {}, + setTextTrackVisibility: () => {} }; stubs.mockPlayer = sandbox.mock(dash.player); @@ -67,6 +70,7 @@ describe('lib/viewers/media/DashViewer', () => { addListener: () => {}, destroy: () => {}, initFilmstrip: () => {}, + initSubtitles: () => {}, removeAllListeners: () => {}, removeListener: () => {} }; @@ -349,6 +353,7 @@ describe('lib/viewers/media/DashViewer', () => { it('should add event listeners to the media controls', () => { Object.defineProperty(VideoBaseViewer.prototype, 'addEventListenersForMediaControls', { value: sandbox.mock() }); stubs.mockControls.expects('addListener').withArgs('qualitychange', sinon.match.func); + stubs.mockControls.expects('addListener').withArgs('subtitlechange', sinon.match.func); dash.addEventListenersForMediaControls(); }); }); @@ -361,7 +366,7 @@ describe('lib/viewers/media/DashViewer', () => { expect(dash.showMedia).to.not.be.called; }); - it('should load the meta data for the media element, show the media/play button, and focus on mediaContainerEl', () => { + it('should load the meta data for the media element, show the media/play button, load subs and set focus', () => { sandbox.stub(dash, 'isDestroyed').returns(false); sandbox.stub(dash, 'showMedia'); sandbox.stub(dash, 'calculateVideoDimensions'); @@ -371,11 +376,13 @@ describe('lib/viewers/media/DashViewer', () => { sandbox.stub(dash, 'handleVolume'); sandbox.stub(dash, 'startBandwidthTracking'); sandbox.stub(dash, 'handleQuality'); + sandbox.stub(dash, 'loadSubtitles'); sandbox.stub(dash, 'showPlayButton'); dash.loadeddataHandler(); expect(dash.showMedia).to.be.called; expect(dash.showPlayButton).to.be.called; + expect(dash.loadSubtitles).to.be.called; expect(dash.emit).to.be.calledWith('load'); expect(dash.loaded).to.be.true; expect(document.activeElement).to.equal(dash.mediaContainerEl); @@ -442,6 +449,119 @@ describe('lib/viewers/media/DashViewer', () => { }); }); + describe('loadSubtitles()', () => { + it('should initialize subtitles in sorted order if there are available subtitles', () => { + const english = { language: 'English', id: 5 }; + const russian = { language: 'Russian', id: 4 }; + const spanish = { language: 'Spanish', id: 6 }; + const korean = { language: 'Korean', id: 3 }; + const arabic = { language: 'Arabic', id: 7 }; + const subs = [ + english, + russian, + spanish, + korean, + arabic + ]; + stubs.mockPlayer.expects('getTextTracks').returns(subs); + stubs.mockControls.expects('initSubtitles').withArgs(['Korean', 'Russian', 'English', 'Spanish', 'Arabic']); + + dash.loadSubtitles(); + + expect(dash.textTracks).to.deep.equal([korean, russian, english, spanish, arabic]); + }); + + it('should do nothing if there are no available subtitles', () => { + const subs = []; + stubs.mockPlayer.expects('getTextTracks').returns(subs); + stubs.mockControls.expects('initSubtitles').never(); + + dash.loadSubtitles(); + }); + }); + + describe('handleSubtitle()', () => { + it('should select track from front of text track list', () => { + const english = { language: 'English', id: 3 }; + const russian = { language: 'Russian', id: 4 }; + const french = { language: 'French', id: 5 }; + const spanish = { language: 'Spanish', id: 6 }; + dash.textTracks = [ + english, + russian, + french, + spanish + ]; + sandbox.stub(cache, 'get').returns('0'); + stubs.mockPlayer.expects('selectTextTrack').withArgs(english); + stubs.mockPlayer.expects('setTextTrackVisibility').withArgs(true); + + dash.handleSubtitle(); + + expect(stubs.emit).to.be.calledWith('subtitlechange', 'English'); + }); + + it('should select track from end of text track list', () => { + const english = { language: 'English', id: 3 }; + const russian = { language: 'Russian', id: 4 }; + const french = { language: 'French', id: 5 }; + const spanish = { language: 'Spanish', id: 6 }; + dash.textTracks = [ + english, + russian, + french, + spanish + ]; + sandbox.stub(cache, 'get').returns('3'); + stubs.mockPlayer.expects('selectTextTrack').withArgs(spanish); + stubs.mockPlayer.expects('setTextTrackVisibility').withArgs(true); + + dash.handleSubtitle(); + + expect(stubs.emit).to.be.calledWith('subtitlechange', 'Spanish'); + }); + + it('should select track from middle of text track list', () => { + const english = { language: 'English', id: 3 }; + const russian = { language: 'Russian', id: 4 }; + const french = { language: 'French', id: 5 }; + const spanish = { language: 'Spanish', id: 6 }; + dash.textTracks = [ + english, + russian, + french, + spanish + ]; + sandbox.stub(cache, 'get').returns('1'); + stubs.mockPlayer.expects('selectTextTrack').withArgs(russian); + stubs.mockPlayer.expects('setTextTrackVisibility').withArgs(true); + + dash.handleSubtitle(); + + expect(stubs.emit).to.be.calledWith('subtitlechange', 'Russian'); + }); + + it('should turn off subtitles when idx out of bounds', () => { + const english = { language: 'English', id: 3 }; + const russian = { language: 'Russian', id: 4 }; + const french = { language: 'French', id: 5 }; + const spanish = { language: 'Spanish', id: 6 }; + dash.textTracks = [ + english, + russian, + french, + spanish + ]; + sandbox.stub(cache, 'get').returns('-1'); + stubs.mockPlayer.expects('selectTextTrack').never(); + stubs.mockPlayer.expects('setTextTrackVisibility').withArgs(false); + + dash.handleSubtitle(); + + expect(stubs.emit).to.be.calledWith('subtitlechange', null); + }); + }); + describe('calculateVideoDimensions()', () => { it('should calculate the video dimensions based on the reps', () => { stubs.mockPlayer.expects('getVariantTracks').returns([{ width: 200 }, { width: 100 }]); diff --git a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js index 3871b7df5..4d2540b43 100644 --- a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js +++ b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.js @@ -45,6 +45,7 @@ describe('lib/viewers/media/MediaBaseViewer', () => { showPauseIcon: sandbox.stub(), showPlayIcon: sandbox.stub(), toggleFullscreen: sandbox.stub(), + toggleSubtitles: sandbox.stub(), updateProgress: sandbox.stub(), updateVolumeIcon: sandbox.stub(), increaseSpeed: sandbox.stub(), @@ -713,6 +714,18 @@ describe('lib/viewers/media/MediaBaseViewer', () => { expect(media.mediaControls.show).to.be.called; }); + it('should toggle subtitles and return true on c', () => { + expect(media.onKeydown('c')).to.be.true; + expect(media.mediaControls.toggleSubtitles).to.be.called; + expect(media.mediaControls.show).to.be.called; + }); + + it('should toggle subtitles and return true on Shift+C', () => { + expect(media.onKeydown('Shift+C')).to.be.true; + expect(media.mediaControls.toggleSubtitles).to.be.called; + expect(media.mediaControls.show).to.be.called; + }); + it('should return false if another key is pressed', () => { expect(media.onKeydown('Esc')).to.be.false; expect(media.mediaControls.show.callCount).to.equal(0); diff --git a/src/lib/viewers/media/__tests__/MediaControls-test.js b/src/lib/viewers/media/__tests__/MediaControls-test.js index 5b5963052..515c82602 100644 --- a/src/lib/viewers/media/__tests__/MediaControls-test.js +++ b/src/lib/viewers/media/__tests__/MediaControls-test.js @@ -64,6 +64,9 @@ describe('lib/viewers/media/MediaControls', () => { expect(mediaControls.settingsButtonEl.getAttribute('title')).to.equal(__('media_settings')); expect(mediaControls.settingsButtonEl.getAttribute('aria-label')).to.equal(__('media_settings')); + expect(mediaControls.subtitlesButtonEl.getAttribute('title')).to.equal(__('media_subtitles_cc')); + expect(mediaControls.subtitlesButtonEl.getAttribute('aria-label')).to.equal(__('media_subtitles_cc')); + expect(mediaControls.timeScrubberEl.getAttribute('aria-valuenow')).to.equal('0'); expect(mediaControls.timeScrubberEl.getAttribute('aria-valuetext')).to.equal('0:00 of 20:10'); expect(mediaControls.volScrubberEl.getAttribute('aria-valuenow')).to.equal('100'); @@ -144,6 +147,7 @@ describe('lib/viewers/media/MediaControls', () => { mediaControls.settingsButtonEl = stubs.genericEl; mediaControls.volButtonEl = stubs.genericEl; mediaControls.playButtonEl = stubs.genericEl; + mediaControls.subtitlesButtonEl = stubs.genericEl; mediaControls.wrapperEl = stubs.genericEl; mediaControls.destroy(); @@ -152,6 +156,7 @@ describe('lib/viewers/media/MediaControls', () => { expect(stubs.removeActivationListener).to.be.calledWith(stubs.genericEl, mediaControls.toggleMuteHandler); expect(stubs.removeActivationListener).to.be.calledWith(stubs.genericEl, mediaControls.toggleFullscreenHandler); expect(stubs.removeActivationListener).to.be.calledWith(stubs.genericEl, mediaControls.toggleSettingsHandler); + expect(stubs.removeActivationListener).to.be.calledWith(stubs.genericEl, mediaControls.toggleSubtitlesHandler); }); }); @@ -173,6 +178,15 @@ describe('lib/viewers/media/MediaControls', () => { }); }); + describe('handleSubtitle', () => { + it('should emit the subtitlechange event', () => { + stubs.emit = sandbox.stub(mediaControls, 'emit'); + + mediaControls.handleSubtitle(); + expect(stubs.emit).to.be.calledWith('subtitlechange'); + }); + }); + describe('setupSettings', () => { it('should create a settings obect and bind listeners', () => { const settingsStub = sandbox.stub(Settings.prototype, 'addListener'); @@ -335,6 +349,18 @@ describe('lib/viewers/media/MediaControls', () => { }); }); + describe('toggleSubtitles', () => { + it('should emit a togglesubtitles message', () => { + sandbox.stub(mediaControls.settings, 'toggleSubtitles'); + stubs.emit = sandbox.stub(mediaControls, 'emit'); + + mediaControls.toggleSubtitles(); + + expect(stubs.emit).to.be.calledWith('togglesubtitles'); + expect(mediaControls.settings.toggleSubtitles).to.be.called; + }); + }); + describe('toggleFullscreen', () => { beforeEach(() => { stubs.emit = sandbox.stub(mediaControls, 'emit'); @@ -489,6 +515,7 @@ describe('lib/viewers/media/MediaControls', () => { expect(stubs.addActivationListener).to.be.calledWith(mediaControls.volButtonEl, mediaControls.toggleMuteHandler); expect(stubs.addActivationListener).to.be.calledWith(mediaControls.fullscreenButtonEl, mediaControls.toggleFullscreenHandler); expect(stubs.addActivationListener).to.be.calledWith(mediaControls.settingsButtonEl, mediaControls.toggleSettingsHandler); + expect(stubs.addActivationListener).to.be.calledWith(mediaControls.subtitlesButtonEl, mediaControls.toggleSubtitlesHandler); expect(stubs.addListener).to.be.called; }); }); @@ -827,5 +854,14 @@ describe('lib/viewers/media/MediaControls', () => { expect(mediaControls.filmstripContainerEl.style.display).to.equal(''); }); }); + + describe('initSubtitles()', () => { + it('should load subtitles', () => { + sandbox.stub(mediaControls.settings, 'loadSubtitles'); + const subs = ['English', 'Russian']; + mediaControls.initSubtitles(subs); + expect(mediaControls.settings.loadSubtitles).to.be.calledWith(subs); + }); + }); }); /* 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 a08f4cc2d..386ef5ea8 100644 --- a/src/lib/viewers/media/__tests__/Settings-test.js +++ b/src/lib/viewers/media/__tests__/Settings-test.js @@ -29,7 +29,13 @@ describe('lib/viewers/media/Settings', () => { it('should have its template set up', () => { expect(settings.settingsEl).to.have.class('bp-media-settings'); expect(settings.settingsEl).to.contain('.bp-media-settings-item'); + }); + + it('should initialize as invisible and without subtitles', () => { expect(settings.visible).to.be.false; + expect(settings.hasSubtitles).to.be.false; + expect(settings.areSubtitlesOn()).to.be.false; + expect(settings.containerEl).to.have.class('bp-media-settings-subtitles-unavailable'); }); }); @@ -549,6 +555,121 @@ describe('lib/viewers/media/Settings', () => { expect(document.querySelector('[data-type="speed"][data-value="1.0"]')).to.not.have.class('bp-media-settings-selected'); expect(document.querySelector('[data-type="speed"][data-value="0.5"]')).to.have.class('bp-media-settings-selected'); }); + + it('should do special handling for subtitles', () => { + sandbox.stub(settings, 'handleSubtitleSelection'); + + settings.chooseOption('subtitles', '-1'); + + expect(settings.handleSubtitleSelection).to.be.called; + }); + + it('should not do special subtitle handling for non-subtitles', () => { + sandbox.stub(settings, 'handleSubtitleSelection'); + + settings.chooseOption('speed', 0.5); + + expect(settings.handleSubtitleSelection).to.not.be.called; + }); + }); + + describe('handleSubtitleSelection()', () => { + it('should save previous value when turning off subtitles', () => { + settings.toggleToSubtitle = '2'; + settings.handleSubtitleSelection('3', '-1'); + + expect(settings.toggleToSubtitle).to.equal('3'); + expect(settings.areSubtitlesOn()).to.equal(false); + expect(settings.containerEl).to.not.have.class('bp-media-settings-subtitles-on'); + }); + + it('should NOT save old value when turning off subtitles if subtitles were already off', () => { + settings.toggleToSubtitle = '2'; + settings.handleSubtitleSelection('-1', '-1'); + + expect(settings.toggleToSubtitle).to.equal('2'); + expect(settings.areSubtitlesOn()).to.equal(false); + expect(settings.containerEl).to.not.have.class('bp-media-settings-subtitles-on'); + }); + + it('should set subtitles-on on container when subtitles are selected', () => { + settings.handleSubtitleSelection('-1', '2'); + + expect(settings.containerEl).to.have.class('bp-media-settings-subtitles-on'); + }); + }); + + describe('toggleSubtitles()', () => { + it('Should turn off subtitles if they were previously on', () => { + sandbox.stub(settings, 'chooseOption'); + sandbox.stub(settings, 'areSubtitlesOn').returns(true); + + settings.toggleSubtitles(); + + expect(settings.chooseOption).to.be.calledWith('subtitles', '-1'); + }); + + it('Should turn on subtitles if they were previously off', () => { + settings.hasSubtitles = true; + sandbox.stub(settings, 'chooseOption'); + sandbox.stub(settings, 'areSubtitlesOn').returns(false); + settings.toggleToSubtitle = '2'; + + settings.toggleSubtitles(); + + expect(settings.chooseOption).to.be.calledWith('subtitles', '2'); + }); + }); + + describe('loadSubtitles()', () => { + it('Should load all subtitles and make them available', () => { + const subsMenu = settings.settingsEl.querySelector('.bp-media-settings-menu-subtitles'); + + settings.loadSubtitles(['English', 'Russian', 'Spanish']); + + expect(subsMenu.children.length).to.equal(5); // Three languages, 'Off', and back to main menu + expect(settings.hasSubtitles).to.be.true; + expect(settings.containerEl).to.not.have.class('bp-media-settings-subtitles-unavailable'); + }); + + it('Should reset menu dimensions after loading', () => { + sandbox.stub(settings, 'setMenuContainerDimensions'); + + settings.loadSubtitles(['English', 'Russian', 'Spanish']); + + expect(settings.setMenuContainerDimensions).to.be.calledWith(settings.settingsEl.firstChild); + }); + + it('Should toggle on subtitles if they were on in the most recently viewed subtitled video', () => { + sandbox.stub(settings, 'chooseOption'); + sandbox.stub(settings, 'areSubtitlesOn').returns(false); + sandbox.stub(cache, 'get').withArgs('media-subtitles').returns('2'); + + settings.loadSubtitles(['English', 'Russian', 'Spanish']); + + expect(settings.chooseOption).to.be.calledWith('subtitles', '0'); + }); + + it('Should not toggle on subtitles if they were off in the most recently viewed subtitled video', () => { + sandbox.stub(settings, 'chooseOption'); + sandbox.stub(settings, 'areSubtitlesOn').returns(false); + sandbox.stub(cache, 'get').withArgs('media-subtitles').returns('-1'); + + settings.loadSubtitles(['English', 'Russian', 'Spanish']); + + expect(settings.chooseOption).to.not.be.called; + }); + + it('Should escape subtitle names', () => { + const subsMenu = settings.settingsEl.querySelector('.bp-media-settings-menu-subtitles'); + + settings.loadSubtitles(['English', '']); + + const sub0 = subsMenu.querySelector('[data-value="0"]').querySelector('.bp-media-settings-value'); + const sub1 = subsMenu.querySelector('[data-value="1"]').querySelector('.bp-media-settings-value'); + expect(sub0.innerHTML).to.equal('English'); + expect(sub1.innerHTML).to.equal('<badboy>'); + }); }); describe('blurHandler()', () => {