\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 @@