Commit 8c26fc68 by polesye

Refactor video volume control.

parent d01af063
......@@ -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');
}
......
......@@ -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();
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();
});
describe('when the video is not playing', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
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);
});
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 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);
});
});
});
......
(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 () {
beforeEach(function () {
spyOn($.fn, 'slider').andCallThrough();
$.cookie.andReturn('75');
state = jasmine.initializePlayer();
});
it('initialize currentVolume to 75%', function () {
expect(state.videoVolumeControl.currentVolume).toEqual(75);
});
it('render the volume control', function () {
expect(state.videoControl.secondaryControlsEl.html())
.toContain("<div class=\"volume\">\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');
});
});
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'
}];
it('render the volume control', function () {
expect(state.videoControl.secondaryControlsEl.html())
.toContain('<div class="volume">\n');
});
beforeEach(function () {
state = jasmine.initializePlayer();
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;
describe('when the new volume is more than 0', function () {
beforeEach(function () {
state.videoVolumeControl.onChange(void 0, {
value: 60
});
});
this.addMatchers({
assertLiveRegionState: function (volume, expectation) {
var region = $('.video-live-region');
it('set the player volume', function () {
expect(state.videoVolumeControl.currentVolume).toEqual(60);
});
var getExpectedText = function (text) {
return text + ' Volume.';
};
it('remote muted class', function () {
expect($('.volume')).not.toHaveClass('muted');
});
this.actual.setVolume(volume, true, true);
return region.text() === getExpectedText(expectation);
}
});
});
describe('when the new volume is 0', function () {
beforeEach(function () {
state.videoVolumeControl.onChange(void 0, {
value: 0
});
});
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('set the player volume', function () {
expect(state.videoVolumeControl.currentVolume).toEqual(0);
});
it('volume is changed on sliding', function () {
volumeControl.onSlideHandler(null, {value: 99});
expect(volumeControl.volume).toBe(99);
});
it('add muted class', function () {
expect($('.volume')).toHaveClass('muted');
});
describe('when the new volume is more than 0', function () {
beforeEach(function () {
volumeControl.setVolume(60, false, true);
});
$.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('set the player volume', function () {
expect(volumeControl.volume).toEqual(60);
});
it('changes ARIA attributes', function () {
var sliderHandle = $(
'div.volume-slider>a.ui-slider-handle'
);
it('remove muted class', function () {
expect($('.volume')).not.toHaveClass('is-muted');
});
});
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);
});
it('set the player volume', function () {
expect(volumeControl.volume).toEqual(0);
});
it('add muted class', function () {
expect($('.volume')).toHaveClass('is-muted');
});
});
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');
});
describe('when the current volume is more than 0', function () {
beforeEach(function () {
state.videoVolumeControl.currentVolume = 60;
state.videoVolumeControl.buttonEl.trigger('click');
});
it('when the new volume is in ]60,80]', function () {
expect(volumeControl).assertLiveRegionState(70, 'Loud');
});
it('save the previous volume', function () {
expect(state.videoVolumeControl.previousVolume).toEqual(60);
});
it('when the new volume is in ]80,100[', function () {
expect(volumeControl).assertLiveRegionState(90, 'Very loud');
});
it('set the player volume', function () {
expect(state.videoVolumeControl.currentVolume).toEqual(0);
});
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');
});
describe('when the current volume is 0', function () {
beforeEach(function () {
state.videoVolumeControl.currentVolume = 0;
state.videoVolumeControl.previousVolume = 60;
state.videoVolumeControl.buttonEl.trigger('click');
});
it('save the previous volume', function () {
expect(volumeControl.storedVolume).toEqual(60);
});
it('set the player volume to previous volume', function () {
expect(state.videoVolumeControl.currentVolume).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);
(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));
......@@ -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,
......
......@@ -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);
}
});
......
......@@ -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();
}));
}
// ***************************************************************
......
(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 = {};
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
this.state = state;
this.state.videoVolumeControl = this;
this.i18n = i18n;
this.initialize();
dfd.resolve();
return dfd.promise();
return $.Deferred().resolve().promise();
};
// ***************************************************************
// Private functions start here.
// ***************************************************************
// 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
};
state.bindTo(methodsDict, state.videoVolumeControl, state);
}
// 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 (!isFinite(currentVolume)) {
currentVolume = 100;
}
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,
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
});
}
/** Initializes the module. */
initialize: function() {
var volume;
/**
* @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}
*/
function _bindHandlers(state) {
state.videoVolumeControl.buttonEl
.on('click', state.videoVolumeControl.toggleMute);
state.videoVolumeControl.el.on('mouseenter', function() {
state.videoVolumeControl.el.addClass('open');
});
state.videoVolumeControl.el.on('mouseleave', function() {
state.videoVolumeControl.el.removeClass('open');
});
// 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.el = this.state.el.find('.volume');
//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();
if (this.state.isTouch) {
// iOS doesn't support volume change
this.el.remove();
return false;
}
});
// 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');
// 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';
// 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;
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)
});
}
// ***************************************************************
// 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),
// 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)
});
} 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)
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);
}
}
// 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');
};
/**
* 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.
*/
var Accessibility = function (button, min, max, i18n) {
this.min = min;
this.max = max;
this.button = button;
this.i18n = i18n;
this.initialize();
};
Accessibility.prototype = {
/** Initializes the module. */
initialize: function() {
this.liveRegion = $('<div />', {
'class': 'sr video-live-region',
'role': 'status',
'aria-hidden': 'false',
'aria-live': 'polite',
'aria-atomic': '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'];
}
return this.i18n['Maximum'];
}
};
// Translators: Volume level equals 100%.
return gettext('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;
}
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
return volume;
},
/**
* Updates volume cookie.
* @param {Number} volume Volume level.
*/
setVolume: function(value) {
$.cookie(this.cookieName, value, {
expires: 3650,
path: '/'
});
}
};
return VolumeControl;
});
}(RequireJS.define));
......@@ -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)) {
......
......@@ -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'),
......
......@@ -79,8 +79,8 @@
<ol class="video-speeds menu" role="menu"></ol>
</div>
<div class="volume">
<a href="#" title="${_('Volume')}" role="button" aria-disabled="false"></a>
<div class="volume-slider-container">
<a href="#" role="button" aria-disabled="false" title="${_('Volume')}" aria-label="${_('Click on this button to mute or unmute this video or press UP or DOWN buttons to increase or decrease volume level.')}"></a>
<div role="presentation" class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment