From 8c26fc6805df91f7c4bf8042246f65a9d9ca4080 Mon Sep 17 00:00:00 2001 From: polesye Date: Wed, 16 Apr 2014 18:43:23 +0300 Subject: [PATCH] Refactor video volume control. --- .../xmodule/xmodule/css/video/display.scss | 4 +- .../js/spec/video/video_player_spec.js | 55 +- .../spec/video/video_volume_control_spec.js | 468 ++++++++----- .../xmodule/xmodule/js/src/video/00_i18n.js | 31 + .../xmodule/js/src/video/01_initialize.js | 11 +- .../xmodule/js/src/video/03_video_player.js | 6 +- .../js/src/video/05_video_quality_control.js | 6 +- .../js/src/video/07_video_volume_control.js | 648 +++++++++++------- .../js/src/video/08_video_speed_control.js | 1 + .../xmodule/video_module/video_module.py | 1 + lms/templates/video.html | 4 +- 11 files changed, 769 insertions(+), 466 deletions(-) create mode 100644 common/lib/xmodule/xmodule/js/src/video/00_i18n.js diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 83aa6daa65..d932d4c114 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -458,14 +458,14 @@ div.video { float: left; position: relative; - &.open { + &.is-opened { .volume-slider-container { display: block; opacity: 1; } } - &.muted { + &.is-muted { & > a { background-image: url('../images/mute.png'); } diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 3b7cd29f49..e97faa900c 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -141,8 +141,7 @@ function (VideoPlayer) { state.videoEl = $('video, iframe'); - expect(state.videoVolumeControl).toBeUndefined(); - expect(state.el.find('div.volume')).not.toExist(); + expect(state.el.find('.volume')).not.toExist(); }); }); }); @@ -450,42 +449,34 @@ function (VideoPlayer) { }, 'currentTime got updated', 10000); }); }); - }); - describe('when the video is not playing', function () { - beforeEach(function () { - state = jasmine.initializePlayer(); + describe('when the video is not playing', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); - spyOn(state.videoPlayer, 'updatePlayTime').andCallThrough(); - spyOn(state, 'setSpeed').andCallThrough(); - spyOn(state.videoPlayer, 'log').andCallThrough(); - spyOn(state.videoPlayer.player, 'setPlaybackRate').andCallThrough(); - spyOn(state.videoPlayer, 'setPlaybackRate').andCallThrough(); - }); + spyOn(state.videoPlayer, 'updatePlayTime').andCallThrough(); + spyOn(state, 'setSpeed').andCallThrough(); + spyOn(state.videoPlayer, 'log').andCallThrough(); + spyOn(state.videoPlayer.player, 'setPlaybackRate').andCallThrough(); + spyOn(state.videoPlayer, 'setPlaybackRate').andCallThrough(); + }); - it('video has a correct speed', function () { - state.speed = '2.0'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.setPlaybackRate) - .toHaveBeenCalledWith('2.0'); - state.videoPlayer.onPlay(); - expect(state.videoPlayer.setPlaybackRate.calls.length) - .toEqual(1); - }); - - it('video has a correct volume', function () { - spyOn(state.videoPlayer.player, 'setVolume'); - state.currentVolume = '0.26'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.player.setVolume) - .toHaveBeenCalledWith('0.26'); + it('video has a correct speed', function () { + state.speed = '2.0'; + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate) + .toHaveBeenCalledWith('2.0'); + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate.calls.length) + .toEqual(1); + }); }); }); describe('onVolumeChange', function () { beforeEach(function () { state = jasmine.initializePlayer(); - + state.videoPlayer.onReady(); state.videoEl = $('video, iframe'); }); @@ -502,10 +493,10 @@ function (VideoPlayer) { it('video has a correct volume', function () { spyOn(state.videoPlayer.player, 'setVolume'); - state.currentVolume = '0.26'; - state.videoPlayer.onPlay(); + state.videoVolumeControl.volume = 26; + state.el.trigger('play'); expect(state.videoPlayer.player.setVolume) - .toHaveBeenCalledWith('0.26'); + .toHaveBeenCalledWith(26); }); }); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js index cf2fed8ba0..7ff313f956 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_volume_control_spec.js @@ -1,209 +1,313 @@ -(function (undefined) { - describe('VideoVolumeControl', function () { - var state, oldOTBD; +(function () { +'use strict'; +describe('VideoVolumeControl', function () { + var state, oldOTBD, volumeControl; + beforeEach(function () { + oldOTBD = window.onTouchBasedDevice; + window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice') + .andReturn(null); + }); + + afterEach(function () { + $('source').remove(); + window.onTouchBasedDevice = oldOTBD; + state.storage.clear(); + }); + + it('Volume level has correct value even if cookie is broken', function () { + $.cookie.andReturn('broken_cookie'); + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + expect(volumeControl.volume).toEqual(100); + }); + + describe('constructor', function () { beforeEach(function () { - oldOTBD = window.onTouchBasedDevice; - window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice') - .andReturn(null); + spyOn($.fn, 'slider').andCallThrough(); + $.cookie.andReturn('75'); + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; }); - afterEach(function () { - $('source').remove(); - window.onTouchBasedDevice = oldOTBD; - state.storage.clear(); + it('initialize volume to 75%', function () { + expect(volumeControl.volume).toEqual(75); }); - describe('constructor', function () { + it('render the volume control', function () { + expect(state.videoControl.secondaryControlsEl.html()) + .toContain('
\n'); + }); + + it('create the slider', function () { + expect($.fn.slider.calls[2].args).toEqual([{ + orientation: 'vertical', + range: 'min', + min: 0, + max: 100, + slide: jasmine.any(Function) + }]); + expect($.fn.slider).toHaveBeenCalledWith( + 'value', volumeControl.volume + ); + }); + + it('add ARIA attributes to live region', function () { + var liveRegion = $('.video-live-region'); + + expect(liveRegion).toHaveAttrs({ + 'role': 'status', + 'aria-live': 'polite', + 'aria-atomic': 'false' + }); + }); + + it('add ARIA attributes to volume control', function () { + var button = $('.volume > a'); + + expect(button).toHaveAttrs({ + 'role': 'button', + 'title': 'Volume', + 'aria-disabled': 'false' + }); + }); + + it('bind the volume control', function () { + var button = $('.volume > a'); + + expect(button).toHandle('keydown'); + expect(button).toHandle('mousedown'); + expect($('.volume')).not.toHaveClass('is-opened'); + + $('.volume').mouseenter(); + expect($('.volume')).toHaveClass('is-opened'); + + $('.volume').mouseleave(); + expect($('.volume')).not.toHaveClass('is-opened'); + }); + }); + + describe('setVolume', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + + this.addMatchers({ + assertLiveRegionState: function (volume, expectation) { + var region = $('.video-live-region'); + + var getExpectedText = function (text) { + return text + ' Volume.'; + }; + + this.actual.setVolume(volume, true, true); + return region.text() === getExpectedText(expectation); + } + }); + }); + + it('update is not called, if new volume equals current', function () { + volumeControl.volume = 60; + spyOn(volumeControl, 'updateSliderView'); + volumeControl.setVolume(60, false, true); + expect(volumeControl.updateSliderView).not.toHaveBeenCalled(); + }); + + it('volume is changed on sliding', function () { + volumeControl.onSlideHandler(null, {value: 99}); + expect(volumeControl.volume).toBe(99); + }); + + describe('when the new volume is more than 0', function () { beforeEach(function () { - spyOn($.fn, 'slider').andCallThrough(); - $.cookie.andReturn('75'); - state = jasmine.initializePlayer(); + volumeControl.setVolume(60, false, true); }); - it('initialize currentVolume to 75%', function () { - expect(state.videoVolumeControl.currentVolume).toEqual(75); + it('set the player volume', function () { + expect(volumeControl.volume).toEqual(60); }); - it('render the volume control', function () { - expect(state.videoControl.secondaryControlsEl.html()) - .toContain("
\n"); - }); - - it('create the slider', function () { - expect($.fn.slider).toHaveBeenCalledWith({ - orientation: "vertical", - range: "min", - min: 0, - max: 100, - value: state.videoVolumeControl.currentVolume, - change: state.videoVolumeControl.onChange, - slide: state.videoVolumeControl.onChange - }); - }); - - it('add ARIA attributes to slider handle', function () { - var sliderHandle = $('div.volume-slider>a.ui-slider-handle'), - arr = [ - 'Muted', 'Very low', 'Low', 'Average', 'Loud', - 'Very loud', 'Maximum' - ]; - - expect(sliderHandle).toHaveAttrs({ - 'role': 'slider', - 'title': 'Volume', - 'aria-disabled': 'false', - 'aria-valuemin': '0', - 'aria-valuemax': '100' - }); - expect(sliderHandle.attr('aria-valuenow')).toBeInRange(0, 100); - expect(sliderHandle.attr('aria-valuetext')).toBeInArray(arr); - }); - - it('add ARIA attributes to volume control', function () { - var volumeControl = $('div.volume>a'); - - expect(volumeControl).toHaveAttrs({ - 'role': 'button', - 'title': 'Volume', - 'aria-disabled': 'false' - }); - }); - - it('bind the volume control', function () { - expect($('.volume>a')).toHandleWith( - 'click', state.videoVolumeControl.toggleMute - ); - expect($('.volume')).not.toHaveClass('open'); - - $('.volume').mouseenter(); - expect($('.volume')).toHaveClass('open'); - - $('.volume').mouseleave(); - expect($('.volume')).not.toHaveClass('open'); + it('remove muted class', function () { + expect($('.volume')).not.toHaveClass('is-muted'); }); }); - describe('onChange', function () { - var initialData = [{ - range: 'Muted', - value: 0, - expectation: 'Muted' - }, { - range: 'in ]0,20]', - value: 10, - expectation: 'Very low' - }, { - range: 'in ]20,40]', - value: 30, - expectation: 'Low' - }, { - range: 'in ]40,60]', - value: 50, - expectation: 'Average' - }, { - range: 'in ]60,80]', - value: 70, - expectation: 'Loud' - }, { - range: 'in ]80,100[', - value: 90, - expectation: 'Very loud' - }, { - range: 'Maximum', - value: 100, - expectation: 'Maximum' - }]; - - beforeEach(function () { - state = jasmine.initializePlayer(); - }); - - describe('when the new volume is more than 0', function () { - beforeEach(function () { - state.videoVolumeControl.onChange(void 0, { - value: 60 - }); - }); - - it('set the player volume', function () { - expect(state.videoVolumeControl.currentVolume).toEqual(60); - }); - - it('remote muted class', function () { - expect($('.volume')).not.toHaveClass('muted'); - }); - }); - - describe('when the new volume is 0', function () { - beforeEach(function () { - state.videoVolumeControl.onChange(void 0, { - value: 0 - }); - }); - - it('set the player volume', function () { - expect(state.videoVolumeControl.currentVolume).toEqual(0); - }); - - it('add muted class', function () { - expect($('.volume')).toHaveClass('muted'); - }); - }); - - $.each(initialData, function (index, data) { - describe('when the new volume is ' + data.range, function () { - beforeEach(function () { - state.videoVolumeControl.onChange(void 0, { - value: data.value - }); - }); - - it('changes ARIA attributes', function () { - var sliderHandle = $( - 'div.volume-slider>a.ui-slider-handle' - ); - - expect(sliderHandle).toHaveAttrs({ - 'aria-valuenow': data.value.toString(10), - 'aria-valuetext': data.expectation - }); - }); - }); + describe('when the new volume is more than 0, but was 0', function () { + it('remove muted class', function () { + volumeControl.setVolume(0, false, true); + expect($('.volume')).toHaveClass('is-muted'); + state.el.trigger('volumechange', [20]); + expect($('.volume')).not.toHaveClass('is-muted'); }); }); - describe('toggleMute', function () { + describe('when the new volume is 0', function () { beforeEach(function () { - state = jasmine.initializePlayer(); + volumeControl.setVolume(0, false, true); }); - describe('when the current volume is more than 0', function () { - beforeEach(function () { - state.videoVolumeControl.currentVolume = 60; - state.videoVolumeControl.buttonEl.trigger('click'); - }); - - it('save the previous volume', function () { - expect(state.videoVolumeControl.previousVolume).toEqual(60); - }); - - it('set the player volume', function () { - expect(state.videoVolumeControl.currentVolume).toEqual(0); - }); + it('set the player volume', function () { + expect(volumeControl.volume).toEqual(0); }); - describe('when the current volume is 0', function () { - beforeEach(function () { - state.videoVolumeControl.currentVolume = 0; - state.videoVolumeControl.previousVolume = 60; - state.videoVolumeControl.buttonEl.trigger('click'); - }); + it('add muted class', function () { + expect($('.volume')).toHaveClass('is-muted'); + }); + }); - it('set the player volume to previous volume', function () { - expect(state.videoVolumeControl.currentVolume).toEqual(60); - }); + it('when the new volume is Muted', function () { + expect(volumeControl).assertLiveRegionState(0, 'Muted'); + }); + + it('when the new volume is in ]0,20]', function () { + expect(volumeControl).assertLiveRegionState(10, 'Very low'); + }); + + it('when the new volume is in ]20,40]', function () { + expect(volumeControl).assertLiveRegionState(30, 'Low'); + }); + + it('when the new volume is in ]40,60]', function () { + expect(volumeControl).assertLiveRegionState(50, 'Average'); + }); + + it('when the new volume is in ]60,80]', function () { + expect(volumeControl).assertLiveRegionState(70, 'Loud'); + }); + + it('when the new volume is in ]80,100[', function () { + expect(volumeControl).assertLiveRegionState(90, 'Very loud'); + }); + + it('when the new volume is Maximum', function () { + expect(volumeControl).assertLiveRegionState(100, 'Maximum'); + }); + }); + + describe('increaseVolume', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + }); + + it('volume is increased correctly', function () { + volumeControl.volume = 60; + state.el.trigger(jQuery.Event("keydown", { + keyCode: $.ui.keyCode.UP + })); + expect(volumeControl.volume).toEqual(80); + }); + + it('volume level is not changed if it is already max', function () { + volumeControl.volume = 100; + volumeControl.increaseVolume(); + expect(volumeControl.volume).toEqual(100); + }); + }); + + describe('decreaseVolume', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + }); + + it('volume is decreased correctly', function () { + volumeControl.volume = 60; + state.el.trigger(jQuery.Event("keydown", { + keyCode: $.ui.keyCode.DOWN + })); + expect(volumeControl.volume).toEqual(40); + }); + + it('volume level is not changed if it is already min', function () { + volumeControl.volume = 0; + volumeControl.decreaseVolume(); + expect(volumeControl.volume).toEqual(0); + }); + }); + + describe('toggleMute', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + }); + + describe('when the current volume is more than 0', function () { + beforeEach(function () { + volumeControl.volume = 60; + volumeControl.button.trigger('mousedown'); + }); + + it('save the previous volume', function () { + expect(volumeControl.storedVolume).toEqual(60); + }); + + it('set the player volume', function () { + expect(volumeControl.volume).toEqual(0); + }); + }); + + describe('when the current volume is 0', function () { + beforeEach(function () { + volumeControl.volume = 0; + volumeControl.storedVolume = 60; + volumeControl.button.trigger('mousedown'); + }); + + it('set the player volume to previous volume', function () { + expect(volumeControl.volume).toEqual(60); }); }); }); + + describe('keyDownHandler', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + }); + + var assertVolumeIsNotChanged = function (eventObject) { + volumeControl.volume = 60; + state.el.trigger(jQuery.Event("keydown", eventObject)); + expect(volumeControl.volume).toEqual(60); + }; + + it('nothing happens if ALT+keyUp are pushed down', function () { + assertVolumeIsNotChanged({ + keyCode: $.ui.keyCode.UP, + altKey: true + }); + }); + + it('nothing happens if SHIFT+keyUp are pushed down', function () { + assertVolumeIsNotChanged({ + keyCode: $.ui.keyCode.UP, + shiftKey: true + }); + }); + + it('nothing happens if SHIFT+keyDown are pushed down', function () { + assertVolumeIsNotChanged({ + keyCode: $.ui.keyCode.DOWN, + shiftKey: true + }); + }); + }) + + describe('keyDownButtonHandler', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + volumeControl = state.videoVolumeControl; + }); + + it('nothing happens if ALT+ENTER are pushed down', function () { + var isMuted = volumeControl.getMuteStatus(); + $('.volume > a').trigger(jQuery.Event("keydown", { + keyCode: $.ui.keyCode.ENTER, + altKey: true + })); + expect(volumeControl.getMuteStatus()).toEqual(isMuted); + }); + }) +}); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js new file mode 100644 index 0000000000..fda1ada13c --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js @@ -0,0 +1,31 @@ +(function (define) { +'use strict'; +define( +'video/00_i18n.js', +[], +function() { + /** + * i18n module. + * @exports video/00_i18n.js + * @return {object} + */ + + return { + 'Volume': gettext('Volume'), + // Translators: Volume level equals 0%. + 'Muted': gettext('Muted'), + // Translators: Volume level in range ]0,20]% + 'Very low': gettext('Very low'), + // Translators: Volume level in range ]20,40]% + 'Low': gettext('Low'), + // Translators: Volume level in range ]40,60]% + 'Average': gettext('Average'), + // Translators: Volume level in range ]60,80]% + 'Loud': gettext('Loud'), + // Translators: Volume level in range ]80,99]% + 'Very loud': gettext('Very loud'), + // Translators: Volume level equals 100%. + 'Maximum': gettext('Maximum') + }; +}); +}(RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 79a7d92078..2dbe66afc3 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -14,8 +14,8 @@ define( 'video/01_initialize.js', -['video/03_video_player.js', 'video/00_video_storage.js'], -function (VideoPlayer, VideoStorage) { +['video/03_video_player.js', 'video/00_video_storage.js', 'video/00_i18n.js'], +function (VideoPlayer, VideoStorage, i18n) { /** * @function * @@ -39,7 +39,7 @@ function (VideoPlayer, VideoStorage) { return false; } - _initializeModules(state) + _initializeModules(state, i18n) .done(function () { // On iPad ready state occurs just after start playing. // We hide controls before video starts playing. @@ -341,11 +341,11 @@ function (VideoPlayer, VideoStorage) { state.captionHideTimeout = null; } - function _initializeModules(state) { + function _initializeModules(state, i18n) { var dfd = $.Deferred(), modulesList = $.map(state.modules, function(module) { if ($.isFunction(module)) { - return module(state); + return module(state, i18n); } else if ($.isPlainObject(module)) { return module; } @@ -494,7 +494,6 @@ function (VideoPlayer, VideoStorage) { __dfd__: __dfd__, el: el, container: container, - currentVolume: 100, id: id, isFullScreen: false, isTouch: isTouch, diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index 07dd005366..090d907b17 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -98,7 +98,6 @@ function (HTML5Video, Resizer) { if (!state.isFlashMode() && state.speed != '1.0') { state.videoPlayer.setPlaybackRate(state.speed); } - state.videoPlayer.player.setVolume(state.currentVolume); }); if (state.isYoutubeType()) { @@ -584,6 +583,10 @@ function (HTML5Video, Resizer) { _this.videoPlayer.onSpeedChange(speed); }); + this.el.on('volumechange volumechange:silent', function (event, volume) { + _this.videoPlayer.onVolumeChange(volume); + }); + this.videoPlayer.log('load_video'); availablePlaybackRates = this.videoPlayer.player @@ -919,7 +922,6 @@ function (HTML5Video, Resizer) { function onVolumeChange(volume) { this.videoPlayer.player.setVolume(volume); - this.el.trigger('volumechange', arguments); } }); diff --git a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js index 8db6576458..580900beed 100644 --- a/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/05_video_quality_control.js @@ -64,9 +64,9 @@ function () { state.videoQualityControl.el.on('click', state.videoQualityControl.toggleQuality ); - state.el.on('play', - _.once(state.videoQualityControl.fetchAvailableQualities) - ); + state.el.on('play', _.once(function () { + state.videoQualityControl.fetchAvailableQualities(); + })); } // *************************************************************** diff --git a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js index f4e8920b29..646fe8e732 100644 --- a/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/07_video_volume_control.js @@ -1,264 +1,438 @@ -(function (requirejs, require, define) { - +(function(define) { +'use strict'; // VideoVolumeControl module. define( -'video/07_video_volume_control.js', -[], -function () { - - // VideoVolumeControl() function - what this module "exports". - return function (state) { - var dfd = $.Deferred(); - - if (state.isTouch) { - // iOS doesn't support volume change - state.el.find('div.volume').remove(); - dfd.resolve(); - return dfd.promise(); +'video/07_video_volume_control.js', [], +function() { + /** + * Video volume control module. + * @exports video/07_video_volume_control.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @return {jquery Promise} + */ + var VolumeControl = function(state, i18n) { + if (!(this instanceof VolumeControl)) { + return new VolumeControl(state, i18n); } - state.videoVolumeControl = {}; + this.state = state; + this.state.videoVolumeControl = this; + this.i18n = i18n; + this.initialize(); - _makeFunctionsPublic(state); - _renderElements(state); - _bindHandlers(state); - - dfd.resolve(); - return dfd.promise(); + return $.Deferred().resolve().promise(); }; - // *************************************************************** - // Private functions start here. - // *************************************************************** + VolumeControl.prototype = { + /** Minimum value for the volume slider. */ + min: 0, + /** Maximum value for the volume slider. */ + max: 100, + /** Step to increase/decrease volume level via keyboard. */ + step: 20, - // function _makeFunctionsPublic(state) - // - // Functions which will be accessible via 'state' object. When called, these functions will - // get the 'state' object as a context. - function _makeFunctionsPublic(state) { - var methodsDict = { - onChange: onChange, - toggleMute: toggleMute - }; + /** Initializes the module. */ + initialize: function() { + var volume; - state.bindTo(methodsDict, state.videoVolumeControl, state); - } + this.el = this.state.el.find('.volume'); - // function _renderElements(state) - // - // Create any necessary DOM elements, attach them, and set their initial configuration. Also - // make the created DOM elements available via the 'state' object. Much easier to work this - // way - you don't have to do repeated jQuery element selects. - function _renderElements(state) { - var volumeControl = state.videoVolumeControl, - element = state.el.find('div.volume'), - button = element.find('a'), - volumeSlider = element.find('.volume-slider'), - // Figure out what the current volume is. If no information about - // volume level could be retrieved, then we will use the default 100 - // level (full volume). - currentVolume = parseInt($.cookie('video_player_volume_level'), 10), - // Set it up so that muting/unmuting works correctly. - previousVolume = 100, - slider, buttonStr, volumeSliderHandleEl; + if (this.state.isTouch) { + // iOS doesn't support volume change + this.el.remove(); + return false; + } + // Youtube iframe react on key buttons and has his own handlers. + // So, we disallow focusing on iframe. + this.state.el.find('iframe').attr('tabindex', -1); + this.button = this.el.children('a'); + this.cookie = new CookieManager(this.min, this.max); + this.a11y = new Accessibility( + this.button, this.min, this.max, this.i18n + ); + volume = this.cookie.getVolume(); + this.storedVolume = this.max; - if (!isFinite(currentVolume)) { - currentVolume = 100; + this.render(); + this.bindHandlers(); + this.setVolume(volume, true, false); + this.checkMuteButtonStatus(volume); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function() { + var container = this.el.find('.volume-slider'); + + this.volumeSlider = container.slider({ + orientation: 'vertical', + range: 'min', + min: this.min, + max: this.max, + slide: this.onSlideHandler.bind(this) + }); + + // We provide an independent behavior to adjust volume level. + // Therefore, we do not need redundant focusing on slider in TAB + // order. + container.find('a').attr('tabindex', -1); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function() { + this.state.el.on({ + 'keydown': this.keyDownHandler.bind(this), + 'play': _.once(this.updateVolumeSilently.bind(this)), + 'volumechange': this.onVolumeChangeHandler.bind(this) + }); + this.el.on({ + 'mouseenter': this.openMenu.bind(this), + 'mouseleave': this.closeMenu.bind(this) + }); + this.button.on({ + 'click': false, + 'mousedown': this.toggleMuteHandler.bind(this), + 'keydown': this.keyDownButtonHandler.bind(this), + 'focus': this.openMenu.bind(this), + 'blur': this.closeMenu.bind(this) + }); + }, + + /** + * Updates volume level without updating view and triggering + * `volumechange` event. + */ + updateVolumeSilently: function() { + this.state.el.trigger( + 'volumechange:silent', [this.getVolume()] + ); + }, + + /** + * Returns current volume level. + * @return {Number} + */ + getVolume: function() { + return this.volume; + }, + + /** + * Sets current volume level. + * @param {Number} volume Suggested volume level + * @param {Boolean} [silent] Sets the new volume level without + * triggering `volumechange` event and updating the cookie. + * @param {Boolean} [withoutSlider] Disables updating the slider. + */ + setVolume: function(volume, silent, withoutSlider) { + if (volume === this.getVolume()) { + return false; + } + + this.volume = volume; + this.a11y.update(this.getVolume()); + + if (!withoutSlider) { + this.updateSliderView(this.getVolume()); + } + + if (!silent) { + this.cookie.setVolume(this.getVolume()); + this.state.el.trigger('volumechange', [this.getVolume()]); + } + }, + + /** Increases current volume level using previously defined step. */ + increaseVolume: function() { + var volume = Math.min(this.getVolume() + this.step, this.max); + + this.setVolume(volume, false, false); + }, + + /** Decreases current volume level using previously defined step. */ + decreaseVolume: function() { + var volume = Math.max(this.getVolume() - this.step, this.min); + + this.setVolume(volume, false, false); + }, + + /** Updates volume slider view. */ + updateSliderView: function (volume) { + this.volumeSlider.slider('value', volume); + }, + + /** + * Mutes or unmutes volume. + * @param {Number} muteStatus Flag to mute/unmute volume. + */ + mute: function(muteStatus) { + var volume; + + this.updateMuteButtonView(muteStatus); + + if (muteStatus) { + this.storedVolume = this.getVolume() || this.max; + } + + volume = muteStatus ? 0 : this.storedVolume; + this.setVolume(volume, false, false); + }, + + /** + * Returns current volume state (is it muted or not?). + * @return {Boolean} + */ + getMuteStatus: function () { + return this.getVolume() === 0; + }, + + /** + * Updates the volume button view. + * @param {Boolean} isMuted Flag to use muted or unmuted view. + */ + updateMuteButtonView: function(isMuted) { + var action = isMuted ? 'addClass' : 'removeClass'; + + this.el[action]('is-muted'); + }, + + /** Toggles the state of the volume button. */ + toggleMute: function() { + this.mute(!this.getMuteStatus()); + }, + + /** + * Checks and updates the state of the volume button relatively to + * volume level. + * @param {Number} volume Volume level. + */ + checkMuteButtonStatus: function (volume) { + if (volume <= this.min) { + this.updateMuteButtonView(true); + this.state.el.off('volumechange.is-muted'); + this.state.el.on('volumechange.is-muted', _.once(function () { + this.updateMuteButtonView(false); + }.bind(this))); + } + }, + + /** Opens volume menu. */ + openMenu: function() { + this.el.addClass('is-opened'); + }, + + /** Closes speed menu. */ + closeMenu: function() { + this.el.removeClass('is-opened'); + }, + + /** + * Keydown event handler for the video container. + * @param {jquery Event} event + */ + keyDownHandler: function(event) { + // ALT key is used to change (alternate) the function of + // other pressed keys. In this case, do nothing. + if (event.altKey) { + return true; + } + + if ($(event.target).hasClass('ui-slider-handle')) { + return true; + } + + var KEY = $.ui.keyCode, + keyCode = event.keyCode; + + switch (keyCode) { + case KEY.UP: + // Shift + Arrows keyboard shortcut might be used by + // screen readers. In this case, do nothing. + if (event.shiftKey) { + return true; + } + + this.increaseVolume(); + return false; + case KEY.DOWN: + // Shift + Arrows keyboard shortcut might be used by + // screen readers. In this case, do nothing. + if (event.shiftKey) { + return true; + } + + this.decreaseVolume(); + return false; + } + + return true; + }, + + /** + * Keydown event handler for the volume button. + * @param {jquery Event} event + */ + keyDownButtonHandler: function(event) { + // ALT key is used to change (alternate) the function of + // other pressed keys. In this case, do nothing. + if (event.altKey) { + return true; + } + + var KEY = $.ui.keyCode, + keyCode = event.keyCode; + + switch (keyCode) { + case KEY.ENTER: + case KEY.SPACE: + this.toggleMute(); + + return false; + } + + return true; + }, + + /** + * onSlide callback for the video slider. + * @param {jquery Event} event + * @param {jqueryuiSlider ui} ui + */ + onSlideHandler: function(event, ui) { + this.setVolume(ui.value, false, true); + }, + + /** + * Mousedown event handler for the volume button. + * @param {jquery Event} event + */ + toggleMuteHandler: function(event) { + this.toggleMute(); + event.preventDefault(); + }, + + /** + * Volumechange event handler. + * @param {jquery Event} event + * @param {Number} volume Volume level. + */ + onVolumeChangeHandler: function(event, volume) { + this.checkMuteButtonStatus(volume); } - - slider = volumeSlider.slider({ - orientation: 'vertical', - range: 'min', - min: 0, - max: 100, - value: currentVolume, - change: volumeControl.onChange, - slide: volumeControl.onChange - }); - - element.toggleClass('muted', currentVolume === 0); - - // ARIA - // Let screen readers know that: - // This anchor behaves as a button named 'Volume'. - buttonStr = (currentVolume === 0) ? 'Volume muted' : 'Volume'; - // We add the aria-label attribute because the title attribute cannot be - // read. - button.attr('aria-label', gettext(buttonStr)); - - // Let screen readers know that this anchor, representing the slider - // handle, behaves as a slider named 'volume'. - volumeSliderHandleEl = slider.find('.ui-slider-handle'); - - volumeSliderHandleEl.attr({ - 'role': 'slider', - 'title': gettext('Volume'), - 'aria-disabled': false, - 'aria-valuemin': slider.slider('option', 'min'), - 'aria-valuemax': slider.slider('option', 'max'), - 'aria-valuenow': slider.slider('option', 'value'), - 'aria-valuetext': getVolumeDescription(slider.slider('option', 'value')) - }); - - - state.currentVolume = currentVolume; - $.extend(state.videoVolumeControl, { - el: element, - buttonEl: button, - volumeSliderEl: volumeSlider, - currentVolume: currentVolume, - previousVolume: previousVolume, - slider: slider, - volumeSliderHandleEl: volumeSliderHandleEl - }); - } + }; /** - * @desc Bind any necessary function callbacks to DOM events (click, - * mousemove, etc.). - * - * @type {function} - * @access private - * - * @param {object} state The object containg the state of the video player. - * All other modules, their parameters, public variables, etc. are - * available via this object. - * - * @this {object} The global window object. - * - * @returns {undefined} + * Module responsible for the accessibility of volume controls. + * @constructor + * @private + * @param {jquery $} button The volume button. + * @param {Number} min Minimum value for the volume slider. + * @param {Number} max Maximum value for the volume slider. + * @param {Object} i18n The object containing strings with translations. */ - function _bindHandlers(state) { - state.videoVolumeControl.buttonEl - .on('click', state.videoVolumeControl.toggleMute); + var Accessibility = function (button, min, max, i18n) { + this.min = min; + this.max = max; + this.button = button; + this.i18n = i18n; - state.videoVolumeControl.el.on('mouseenter', function() { - state.videoVolumeControl.el.addClass('open'); - }); + this.initialize(); + }; - state.videoVolumeControl.el.on('mouseleave', function() { - state.videoVolumeControl.el.removeClass('open'); - }); + Accessibility.prototype = { + /** Initializes the module. */ + initialize: function() { + this.liveRegion = $('
', { + 'class': 'sr video-live-region', + 'role': 'status', + 'aria-hidden': 'false', + 'aria-live': 'polite', + 'aria-atomic': 'false' + }); - // Attach a focus event to the volume button. - state.videoVolumeControl.buttonEl.on('blur', function() { - // If the focus is being trasnfered from the volume slider, then we - // don't do anything except for unsetting the special flag. - if (state.volumeBlur === true) { - state.volumeBlur = false; + this.button.after(this.liveRegion); + }, + + /** + * Updates text of the live region. + * @param {Number} volume Volume level. + */ + update: function(volume) { + this.liveRegion.text([ + this.getVolumeDescription(volume), + this.i18n['Volume'] + '.' + ].join(' ')); + }, + + /** + * Returns a string describing the level of volume. + * @param {Number} volume Volume level. + */ + getVolumeDescription: function(volume) { + if (volume === 0) { + return this.i18n['Muted']; + } else if (volume <= 20) { + return this.i18n['Very low']; + } else if (volume <= 40) { + return this.i18n['Low']; + } else if (volume <= 60) { + return this.i18n['Average']; + } else if (volume <= 80) { + return this.i18n['Loud']; + } else if (volume <= 99) { + return this.i18n['Very loud']; } - //If the focus is comming from elsewhere, then we must show the - // volume slider and set focus to it. - else { - state.videoVolumeControl.el.addClass('open'); - state.videoVolumeControl.volumeSliderEl.find('a').focus(); + return this.i18n['Maximum']; + } + }; + + /** + * Module responsible for the work with volume cookie. + * @constructor + * @private + * @param {Number} min Minimum value for the volume slider. + * @param {Number} max Maximum value for the volume slider. + */ + var CookieManager = function (min, max) { + this.min = min; + this.max = max; + this.cookieName = 'video_player_volume_level'; + }; + + CookieManager.prototype = { + /** + * Returns volume level from the cookie. + * @return {Number} Volume level. + */ + getVolume: function() { + var volume = parseInt($.cookie(this.cookieName), 10); + + if (_.isFinite(volume)) { + volume = Math.max(volume, this.min); + volume = Math.min(volume, this.max); + } else { + volume = this.max; } - }); - // Attach a blur event handler (loss of focus) to the volume slider - // element. More specifically, we are attaching to the handle on - // the slider with which you can change the volume. - state.videoVolumeControl.volumeSliderEl.find('a') - .on('blur', function () { - // Hide the volume slider. This is done so that we can - // continue to the next (or previous) element by tabbing. - // Otherwise, after next tab we would come back to the volume - // slider because it is the next element visible element that - // we can tab to after the volume button. - state.videoVolumeControl.el.removeClass('open'); + return volume; + }, - // Set focus to the volume button. - state.videoVolumeControl.buttonEl.focus(); - - // We store the fact that previous element that lost focus was - // the volume clontrol. - state.volumeBlur = true; - // The following field is used in video_speed_control to track - // the element that had the focus before it. - state.previousFocus = 'volume'; - }); - } - - // *************************************************************** - // Public functions start here. - // These are available via the 'state' object. Their context ('this' keyword) is the 'state' object. - // The magic private function that makes them available and sets up their context is makeFunctionsPublic(). - // *************************************************************** - - function onChange(event, ui) { - var currentVolume = ui.value, - ariaLabelText = (currentVolume === 0) ? 'Volume muted' : 'Volume'; - - this.videoVolumeControl.currentVolume = currentVolume; - this.videoVolumeControl.el.toggleClass('muted', currentVolume === 0); - - $.cookie('video_player_volume_level', ui.value, { - expires: 3650, - path: '/' - }); - - this.trigger('videoPlayer.onVolumeChange', ui.value); - - // ARIA - this.videoVolumeControl.volumeSliderHandleEl.attr({ - 'aria-valuenow': ui.value, - 'aria-valuetext': getVolumeDescription(ui.value) - }); - - this.videoVolumeControl.buttonEl.attr( - 'aria-label', gettext(ariaLabelText) - ); - } - - function toggleMute(event) { - event.preventDefault(); - - if (this.videoVolumeControl.currentVolume > 0) { - this.videoVolumeControl.previousVolume = this.videoVolumeControl.currentVolume; - this.videoVolumeControl.slider.slider('option', 'value', 0); - // ARIA - this.videoVolumeControl.volumeSliderHandleEl.attr({ - 'aria-valuenow': 0, - 'aria-valuetext': getVolumeDescription(0), - }); - } else { - this.videoVolumeControl.slider.slider('option', 'value', this.videoVolumeControl.previousVolume); - // ARIA - this.videoVolumeControl.volumeSliderHandleEl.attr({ - 'aria-valuenow': this.videoVolumeControl.previousVolume, - 'aria-valuetext': getVolumeDescription(this.videoVolumeControl.previousVolume) + /** + * Updates volume cookie. + * @param {Number} volume Volume level. + */ + setVolume: function(value) { + $.cookie(this.cookieName, value, { + expires: 3650, + path: '/' }); } - } - - // ARIA - // Returns a string describing the level of volume. - function getVolumeDescription(vol) { - if (vol === 0) { - // Translators: Volume level equals 0%. - return gettext('Muted'); - } else if (vol <= 20) { - // Translators: Volume level in range (0,20]% - return gettext('Very low'); - } else if (vol <= 40) { - // Translators: Volume level in range (20,40]% - return gettext('Low'); - } else if (vol <= 60) { - // Translators: Volume level in range (40,60]% - return gettext('Average'); - } else if (vol <= 80) { - // Translators: Volume level in range (60,80]% - return gettext('Loud'); - } else if (vol <= 99) { - // Translators: Volume level in range (80,100)% - return gettext('Very loud'); - } - - // Translators: Volume level equals 100%. - return gettext('Maximum'); - } + }; + return VolumeControl; }); - -}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); +}(RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js index 50c2a0d703..7467574024 100644 --- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js @@ -9,6 +9,7 @@ function (Iterator) { * @exports video/08_video_speed_control.js * @constructor * @param {object} state The object containing the state of the video player. + * @return {jquery Promise} */ var SpeedControl = function (state) { if (!(this instanceof SpeedControl)) { diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 659cdfcca3..ec9accca30 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -71,6 +71,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule): resource_string(module, 'js/src/video/00_video_storage.js'), resource_string(module, 'js/src/video/00_resizer.js'), resource_string(module, 'js/src/video/00_async_process.js'), + resource_string(module, 'js/src/video/00_i18n.js'), resource_string(module, 'js/src/video/00_sjson.js'), resource_string(module, 'js/src/video/00_iterator.js'), resource_string(module, 'js/src/video/01_initialize.js'), diff --git a/lms/templates/video.html b/lms/templates/video.html index 8791ffbbf7..168663232d 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -79,8 +79,8 @@
- -
+ +