Commit 43ce6421 by jmclaus

Merge pull request #5059 from edx/anton/add-video-context-menu

BLD-1230: Video Link Obfuscation
parents 32a45e19 2997a80a
......@@ -17,11 +17,6 @@
.xmodule_VideoModule {
// display mode
&.xblock-student_view {
// full screen
.video-controls .add-fullscreen {
display: none !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
}
.video-tracks {
.a11y-menu-container {
.a11y-menu-list {
......
......@@ -131,3 +131,96 @@ $a11y--blue-s1: saturate($blue,15%);
}
}
}
.contextmenu, .submenu {
border: 1px solid #333;
background: #fff;
color: #333;
padding: 0;
margin: 0;
list-style: none;
position: absolute;
top: 0;
display: none;
z-index: 999999;
outline: none;
cursor: default;
white-space: nowrap;
&.is-opened {
display: block;
}
.menu-item, .submenu-item {
border-top: 1px solid #ccc;
padding: 5px 10px;
outline: none;
& > span {
color: #333;
}
&:first-child {
border-top: none;
}
&:focus {
background: #333;
color: #fff;
& > span {
color: #fff;
}
}
}
.submenu-item {
position: relative;
padding: 5px 20px 5px 10px;
&:after {
content: '\25B6';
position: absolute;
right: 5px;
line-height: 25px;
font-size: 10px;
}
.submenu {
display: none;
}
&.is-opened {
background: #333;
color: #fff;
& > span {
color: #fff;
}
& > .submenu {
display: block;
}
}
.is-selected {
font-weight: bold;
}
}
.is-disabled {
pointer-events: none;
color: #ccc;
}
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 900000;
background-color: transparent;
}
......@@ -689,8 +689,9 @@ div.video {
position: fixed;
top: 0;
width: 100%;
z-index: 999;
z-index: 9999;
vertical-align: middle;
border-radius: 0;
&.closed {
div.tc-wrapper {
......
(function () {
'use strict';
describe('Video Context Menu', function () {
var state, openMenu, keyPressEvent, openSubmenuMouse, openSubmenuKeyboard, closeSubmenuMouse,
closeSubmenuKeyboard, menu, menuItems, menuSubmenuItem, submenu, submenuItems, overlay, playButton;
openMenu = function () {
var container = $('div.video');
jasmine.Clock.useMock();
container.find('video').trigger('contextmenu');
menu = container.children('ol.contextmenu');
menuItems = menu.children('li.menu-item').not('.submenu-item');
menuSubmenuItem = menu.children('li.menu-item.submenu-item');
submenu = menuSubmenuItem.children('ol.submenu');
submenuItems = submenu.children('li.menu-item');
overlay = container.children('div.overlay');
playButton = $('a.video_control.play');
};
keyPressEvent = function(key) {
return $.Event('keydown', {keyCode: key});
};
openSubmenuMouse = function (menuSubmenuItem) {
menuSubmenuItem.mouseover();
jasmine.Clock.tick(200);
expect(menuSubmenuItem).toHaveClass('is-opened');
};
openSubmenuKeyboard = function (menuSubmenuItem, keyCode) {
menuSubmenuItem.focus().trigger(keyPressEvent(keyCode || $.ui.keyCode.RIGHT));
expect(menuSubmenuItem).toHaveClass('is-opened');
expect(menuSubmenuItem.children().first()).toBeFocused();
};
closeSubmenuMouse = function (menuSubmenuItem) {
menuSubmenuItem.mouseleave();
jasmine.Clock.tick(200);
expect(menuSubmenuItem).not.toHaveClass('is-opened');
};
closeSubmenuKeyboard = function (menuSubmenuItem) {
menuSubmenuItem.children().first().focus().trigger(keyPressEvent($.ui.keyCode.LEFT));
expect(menuSubmenuItem).not.toHaveClass('is-opened');
expect(menuSubmenuItem).toBeFocused();
};
beforeEach(function () {
// $.cookie is mocked, make sure we have a state with an unmuted volume.
$.cookie.andReturn('100');
this.addMatchers({
toBeFocused: function () {
return {
compare: function (actual) {
return { pass: $(actual)[0] === $(actual)[0].ownerDocument.activeElement };
}
};
},
toHaveCorrectLabels: function (labelsList) {
return _.difference(labelsList, _.map(this.actual, function (item) {
return $(item).text();
})).length === 0;
}
});
});
afterEach(function () {
$('source').remove();
_.result(state.storage, 'clear');
_.result($('video').data('contextmenu'), 'destroy');
});
describe('constructor', function () {
it('the structure should be created on first `contextmenu` call', function () {
state = jasmine.initializePlayer();
expect(menu).not.toExist();
openMenu();
/*
Make sure we have the expected HTML structure:
- Play (Pause)
- Mute (Unmute)
- Fill browser (Exit full browser)
- Speed >
- 0.75x
- 1.0x
- 1.25x
- 1.50x
*/
// Only one context menu per video container
expect(menu).toExist();
expect(menu).toHaveClass('is-opened');
expect(menuItems).toHaveCorrectLabels(['Play', 'Mute', 'Fill browser']);
expect(menuSubmenuItem.children('span')).toHaveText('Speed');
expect(submenuItems).toHaveCorrectLabels(['0.75x', '1.0x', '1.25x', '1.50x']);
// Check that one of the speed submenu item is selected
expect(_.size(submenuItems.filter('.is-selected'))).toBe(1);
});
it('add ARIA attributes to menu, menu items, submenu and submenu items', function () {
state = jasmine.initializePlayer();
openMenu();
// Menu and its items.
expect(menu).toHaveAttr('role', 'menu');
menuItems.each(function () {
expect($(this)).toHaveAttrs({
'aria-selected': 'false',
'role': 'menuitem'
});
});
expect(menuSubmenuItem).toHaveAttrs({
'aria-expanded': 'false',
'aria-haspopup': 'true',
'role': 'menuitem'
});
// Submenu and its items.
expect(submenu).toHaveAttr('role', 'menu');
submenuItems.each(function () {
expect($(this)).toHaveAttr('role', 'menuitem');
expect($(this)).toHaveAttr('aria-selected');
});
});
it('is not used by Youtube type of video player', function () {
state = jasmine.initializePlayer('video.html');
expect($('video, iframe')).not.toHaveData('contextmenu');
});
});
describe('methods:', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
openMenu();
});
it('menu can be destroyed successfully', function () {
var menuitemEvents = ['click', 'keydown', 'contextmenu', 'mouseover'],
menuEvents = ['keydown', 'contextmenu', 'mouseleave', 'mouseover'];
menu.data('menu').destroy();
expect(menu).not.toExist();
expect(overlay).not.toExist();
_.each(menuitemEvents, function (eventName) {
expect(menuItems.first()).not.toHandle(eventName);
})
_.each(menuEvents, function (eventName) {
expect(menuSubmenuItem).not.toHandle(eventName);
})
_.each(menuEvents, function (eventName) {
expect(menu).not.toHandle(eventName);
})
expect($('video')).not.toHandle('contextmenu');
expect($('video')).not.toHaveData('contextmenu');
});
it('can change label for the submenu', function () {
expect(menuSubmenuItem.children('span')).toHaveText('Speed');
menuSubmenuItem.data('menu').setLabel('New Name');
expect(menuSubmenuItem.children('span')).toHaveText('New Name');
});
it('can change label for the menuitem', function () {
expect(menuItems.first()).toHaveText('Play');
menuItems.first().data('menu').setLabel('Pause');
expect(menuItems.first()).toHaveText('Pause');
});
});
describe('when video is right-clicked', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
openMenu();
});
it('context menu opens', function () {
expect(menu).toHaveClass('is-opened');
expect(overlay).toExist();
});
it('mouseover and mouseleave behave as expected', function () {
openSubmenuMouse(menuSubmenuItem);
expect(menuSubmenuItem).toHaveClass('is-opened');
closeSubmenuMouse(menuSubmenuItem);
expect(menuSubmenuItem).not.toHaveClass('is-opened');
submenuItems.eq(1).mouseover();
expect(submenuItems.eq(1)).toBeFocused();
});
it('mouse left-clicking outside of the context menu will close it', function () {
// Left-click outside of open menu, for example on Play button
playButton.click();
expect(menu).not.toHaveClass('is-opened');
expect(overlay).not.toExist();
});
it('mouse right-clicking outside of video will close it', function () {
// Right-click outside of open menu for example on Play button
playButton.trigger('contextmenu');
expect(menu).not.toHaveClass('is-opened');
expect(overlay).not.toExist();
});
it('mouse right-clicking inside video but outside of context menu will not close it', function () {
spyOn(menu.data('menu'), 'pointInContainerBox').andReturn(true);
overlay.trigger('contextmenu');
expect(menu).toHaveClass('is-opened');
expect(overlay).toExist();
});
it('mouse right-clicking inside video but outside of context menu will close submenus', function () {
spyOn(menu.data('menu'), 'pointInContainerBox').andReturn(true);
openSubmenuMouse(menuSubmenuItem);
expect(menuSubmenuItem).toHaveClass('is-opened');
overlay.trigger('contextmenu');
expect(menuSubmenuItem).not.toHaveClass('is-opened');
});
it('mouse left/right-clicking behaves as expected on play/pause menu item', function () {
var menuItem = menuItems.first();
runs(function () {
// Left-click on play
menuItem.click();
});
waitsFor(function () {
return state.videoPlayer.isPlaying();
}, 'video to start playing', 200);
runs(function () {
expect(menuItem).toHaveText('Pause');
openMenu();
// Left-click on pause
menuItem.click();
});
waitsFor(function () {
return !state.videoPlayer.isPlaying();
}, 'video to start playing', 200);
runs(function () {
expect(menuItem).toHaveText('Play');
// Right-click on play
menuItem.trigger('contextmenu');
});
waitsFor(function () {
return state.videoPlayer.isPlaying();
}, 'video to start playing', 200);
runs(function () {
expect(menuItem).toHaveText('Pause');
});
});
it('mouse left/right-clicking behaves as expected on mute/unmute menu item', function () {
var menuItem = menuItems.eq(1);
// Left-click on mute
menuItem.click();
expect(state.videoVolumeControl.getMuteStatus()).toBe(true);
expect(menuItem).toHaveText('Unmute');
openMenu();
// Left-click on unmute
menuItem.click();
expect(state.videoVolumeControl.getMuteStatus()).toBe(false);
expect(menuItem).toHaveText('Mute');
// Right-click on mute
menuItem.trigger('contextmenu');
expect(state.videoVolumeControl.getMuteStatus()).toBe(true);
expect(menuItem).toHaveText('Unmute');
openMenu();
// Right-click on unmute
menuItem.trigger('contextmenu');
expect(state.videoVolumeControl.getMuteStatus()).toBe(false);
expect(menuItem).toHaveText('Mute');
});
it('mouse left/right-clicking behaves as expected on go to Exit full browser menu item', function () {
var menuItem = menuItems.eq(2);
// Left-click on Fill browser
menuItem.click();
expect(state.isFullScreen).toBe(true);
expect(menuItem).toHaveText('Exit full browser');
openMenu();
// Left-click on Exit full browser
menuItem.click();
expect(state.isFullScreen).toBe(false);
expect(menuItem).toHaveText('Fill browser');
// Right-click on Fill browser
menuItem.trigger('contextmenu');
expect(state.isFullScreen).toBe(true);
expect(menuItem).toHaveText('Exit full browser');
openMenu();
// Right-click on Exit full browser
menuItem.trigger('contextmenu');
expect(state.isFullScreen).toBe(false);
expect(menuItem).toHaveText('Fill browser');
});
it('mouse left/right-clicking behaves as expected on speed submenu item', function () {
// Set speed to 0.75x
state.videoSpeedControl.setSpeed('0.75');
// Left-click on second submenu speed (1.0x)
openSubmenuMouse(menuSubmenuItem);
submenuItems.eq(1).click();
// Expect speed to be 1.0x
expect(state.videoSpeedControl.currentSpeed).toBe('1.0');
// Expect speed submenu item 0.75x not to be active
expect(submenuItems.first()).not.toHaveClass('is-selected');
// Expect speed submenu item 1.0x to be active
expect(submenuItems.eq(1)).toHaveClass('is-selected');
// Set speed to 0.75x
state.videoSpeedControl.setSpeed('0.75');
// Right-click on second submenu speed (1.0x)
openSubmenuMouse(menuSubmenuItem);
submenuItems.eq(1).trigger('contextmenu');
// Expect speed to be 1.0x
expect(state.videoSpeedControl.currentSpeed).toBe('1.0');
// Expect speed submenu item 0.75x not to be active
expect(submenuItems.first()).not.toHaveClass('is-selected');
// Expect speed submenu item 1.0x to be active
expect(submenuItems.eq(1)).toHaveClass('is-selected');
});
});
describe('Keyboard interactions', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
openMenu();
});
it('focus the first item of the just opened menu on UP keydown', function () {
menu.trigger(keyPressEvent($.ui.keyCode.UP));
expect(menuSubmenuItem).toBeFocused();
});
it('focus the last item of the just opened menu on DOWN keydown', function () {
menu.trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuItems.first()).toBeFocused();
});
it('open the submenu on ENTER keydown', function () {
openSubmenuKeyboard(menuSubmenuItem, $.ui.keyCode.ENTER);
expect(menuSubmenuItem).toHaveClass('is-opened');
expect(submenuItems.first()).toBeFocused();
});
it('open the submenu on SPACE keydown', function () {
openSubmenuKeyboard(menuSubmenuItem, $.ui.keyCode.SPACE);
expect(menuSubmenuItem).toHaveClass('is-opened');
expect(submenuItems.first()).toBeFocused();
});
it('open the submenu on RIGHT keydown', function () {
openSubmenuKeyboard(menuSubmenuItem, $.ui.keyCode.RIGHT);
expect(menuSubmenuItem).toHaveClass('is-opened');
expect(submenuItems.first()).toBeFocused();
});
it('close the menu on ESCAPE keydown', function () {
menu.trigger(keyPressEvent($.ui.keyCode.ESCAPE));
expect(menu).not.toHaveClass('is-opened');
expect(overlay).not.toExist();
});
it('close the submenu on ESCAPE keydown', function () {
openSubmenuKeyboard(menuSubmenuItem);
menuSubmenuItem.trigger(keyPressEvent($.ui.keyCode.ESCAPE));
expect(menuSubmenuItem).not.toHaveClass('is-opened');
expect(overlay).not.toExist();
});
it('close the submenu on LEFT keydown on submenu items', function () {
closeSubmenuKeyboard(menuSubmenuItem);
});
it('do nothing on RIGHT keydown on submenu item', function () {
submenuItems.eq(1).focus().trigger(keyPressEvent($.ui.keyCode.RIGHT)); // Mute
// Is still focused.
expect(submenuItems.eq(1)).toBeFocused();
});
it('do nothing on TAB keydown on menu item', function () {
submenuItems.eq(1).focus().trigger(keyPressEvent($.ui.keyCode.TAB)); // Mute
// Is still focused.
expect(submenuItems.eq(1)).toBeFocused();
});
it('UP and DOWN keydown function as expected on menu/submenu items', function () {
menuItems.eq(0).focus(); // Play
expect(menuItems.eq(0)).toBeFocused();
menuItems.eq(0).trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuItems.eq(1)).toBeFocused(); // Mute
menuItems.eq(1).trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuItems.eq(2)).toBeFocused(); // Fullscreen
menuItems.eq(2).trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuSubmenuItem).toBeFocused(); // Speed
menuSubmenuItem.trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuItems.eq(0)).toBeFocused(); // Play
menuItems.eq(0).trigger(keyPressEvent($.ui.keyCode.UP));
expect(menuSubmenuItem).toBeFocused(); // Speed
menuSubmenuItem.trigger(keyPressEvent($.ui.keyCode.UP));
// Check if hidden item can be skipped correctly.
menuItems.eq(2).hide(); // hide Fullscreen item
expect(menuItems.eq(1)).toBeFocused(); // Mute
menuItems.eq(1).trigger(keyPressEvent($.ui.keyCode.UP));
expect(menuItems.eq(0)).toBeFocused(); // Play
});
it('current item is still focused if all siblings are hidden', function () {
menuItems.eq(0).focus(); // Play
expect(menuItems.eq(0)).toBeFocused(); // hide all siblings
menuItems.eq(0).siblings().hide();
menuSubmenuItem.trigger(keyPressEvent($.ui.keyCode.DOWN));
expect(menuItems.eq(0)).toBeFocused();
menuSubmenuItem.trigger(keyPressEvent($.ui.keyCode.UP));
expect(menuItems.eq(0)).toBeFocused();
});
it('ENTER keydown on menu/submenu item selects its data and closes menu', function () {
menuItems.eq(2).focus().trigger(keyPressEvent($.ui.keyCode.ENTER)); // Fullscreen
expect(menuItems.eq(2)).toHaveClass('is-selected');
expect(menuItems.eq(2).siblings()).not.toHaveClass('is-selected');
expect(state.isFullScreen).toBeTruthy();
expect(menuItems.eq(2)).toHaveText('Exit full browser');
});
it('SPACE keydown on menu/submenu item selects its data and closes menu', function () {
submenuItems.eq(2).focus().trigger(keyPressEvent($.ui.keyCode.SPACE)); // 1.25x
expect(submenuItems.eq(2)).toHaveClass('is-selected');
expect(submenuItems.eq(2).siblings()).not.toHaveClass('is-selected');
expect(state.videoSpeedControl.currentSpeed).toBe('1.25');
});
});
});
})();
(function (define) {
'use strict';
define('video/00_component.js', [],
function () {
/**
* Creates a new object with the specified prototype object and properties.
* @param {Object} o The object which should be the prototype of the
* newly-created object.
* @private
* @throws {TypeError, Error}
* @return {Object}
*/
var inherit = Object.create || (function () {
var F = function () {};
return function (o) {
if (arguments.length > 1) {
throw Error('Second argument not supported');
}
if (_.isNull(o) || _.isUndefined(o)) {
throw Error('Cannot set a null [[Prototype]]');
}
if (!_.isObject(o)) {
throw TypeError('Argument must be an object');
}
F.prototype = o;
return new F();
};
})();
/**
* Component module.
* @exports video/00_component.js
* @constructor
* @return {jquery Promise}
*/
var Component = function () {
if ($.isFunction(this.initialize)) {
return this.initialize.apply(this, arguments);
}
};
/**
* Returns new constructor that inherits form the current constructor.
* @static
* @param {Object} protoProps The object containing which will be added to
* the prototype.
* @return {Object}
*/
Component.extend = function (protoProps, staticProps) {
var Parent = this,
Child = function () {
if ($.isFunction(this.initialize)) {
return this.initialize.apply(this, arguments);
}
};
// Inherit methods and properties from the Parent prototype.
Child.prototype = inherit(Parent.prototype);
Child.constructor = Parent;
// Provide access to parent's methods and properties
Child.__super__ = Parent.prototype;
// Extends inherited methods and properties by methods/properties
// passed as argument.
if (protoProps) {
$.extend(Child.prototype, protoProps);
}
// Inherit static methods and properties
$.extend(Child, Parent, staticProps);
return Child;
};
return Component;
});
}(RequireJS.define));
......@@ -11,6 +11,13 @@ function() {
*/
return {
'Play': gettext('Play'),
'Pause': gettext('Pause'),
'Mute': gettext('Mute'),
'Unmute': gettext('Unmute'),
'Exit full browser': gettext('Exit full browser'),
'Fill browser': gettext('Fill browser'),
'Speed': gettext('Speed'),
'Volume': gettext('Volume'),
// Translators: Volume level equals 0%.
'Muted': gettext('Muted'),
......
......@@ -30,7 +30,7 @@ function () {
// get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
exitFullScreen: exitFullScreen,
exitFullScreenHandler: exitFullScreenHandler,
hideControls: hideControls,
hidePlayPlaceholder: hidePlayPlaceholder,
pause: pause,
......@@ -39,6 +39,7 @@ function () {
showControls: showControls,
showPlayPlaceholder: showPlayPlaceholder,
toggleFullScreen: toggleFullScreen,
toggleFullScreenHandler: toggleFullScreenHandler,
togglePlayback: togglePlayback,
updateControlsHeight: updateControlsHeight,
updateVcrVidTime: updateVcrVidTime
......@@ -93,7 +94,7 @@ function () {
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
function _bindHandlers(state) {
state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback);
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreen);
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreenHandler);
state.el.on('fullscreen', function (event, isFullScreen) {
var height = state.videoControl.updateControlsHeight();
......@@ -111,7 +112,7 @@ function () {
}
});
$(document).on('keyup', state.videoControl.exitFullScreen);
$(document).on('keyup', state.videoControl.exitFullScreenHandler);
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
state.el.on('mousemove', state.videoControl.showControls);
......@@ -246,19 +247,22 @@ function () {
function togglePlayback(event) {
event.preventDefault();
if (this.videoControl.isPlaying) {
this.trigger('videoPlayer.pause', null);
} else {
this.trigger('videoPlayer.play', null);
}
this.videoCommands.execute('togglePlayback');
}
function toggleFullScreen(event) {
/**
* Event handler to toggle fullscreen mode.
* @param {jquery Event} event
*/
function toggleFullScreenHandler(event) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
/** Toggle fullscreen mode. */
function toggleFullScreen() {
var fullScreenClassNameEl = this.el.add(document.documentElement),
win = $(window),
text;
win = $(window), text;
if (this.videoControl.fullScreenState) {
this.videoControl.fullScreenState = this.isFullScreen = false;
......@@ -280,9 +284,14 @@ function () {
this.el.trigger('fullscreen', [this.isFullScreen]);
}
function exitFullScreen(event) {
/**
* Event handler to exit from fullscreen mode.
* @param {jquery Event} event
*/
function exitFullScreenHandler(event) {
if ((this.isFullScreen) && (event.keyCode === 27)) {
this.videoControl.toggleFullScreen(event);
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
}
......
......@@ -198,7 +198,7 @@ function (Iterator) {
var speed = $(event.currentTarget).parent().data('speed');
this.closeMenu();
this.setSpeed(this.state.speedToString(speed));
this.state.videoCommands.execute('speed', speed);
return false;
},
......
(function (define) {
'use strict';
// VideoContextMenu module.
define(
'video/095_video_context_menu.js',
['video/00_component.js'],
function (Component) {
var AbstractItem, AbstractMenu, Menu, Overlay, Submenu, MenuItem;
AbstractItem = Component.extend({
initialize: function (options) {
this.options = $.extend(true, {
label: '',
prefix: 'edx-',
dataAttrs: {menu: this},
attrs: {},
items: [],
callback: $.noop,
initialize: $.noop
}, options);
this.id = _.uniqueId();
this.element = this.createElement();
this.element.attr(this.options.attrs).data(this.options.dataAttrs);
this.children = [];
this.delegateEvents();
this.options.initialize.call(this, this);
},
destroy: function () {
_.invoke(this.getChildren(), 'destroy');
this.undelegateEvents();
this.getElement().remove();
},
open: function () {
this.getElement().addClass('is-opened');
return this;
},
close: function () { },
closeSiblings: function () {
_.invoke(this.getSiblings(), 'close');
return this;
},
getElement: function () {
return this.element;
},
addChild: function (child) {
var firstChild = null, lastChild = null;
if (this.hasChildren()) {
lastChild = this.getLastChild();
lastChild.next = child;
firstChild = this.getFirstChild();
firstChild.prev = child;
}
child.parent = this;
child.next = firstChild;
child.prev = lastChild;
this.children.push(child);
return this;
},
getChildren: function () {
// Returns the copy.
return this.children.concat();
},
hasChildren: function () {
return this.getChildren().length > 0;
},
getFirstChild: function () {
return _.first(this.children);
},
getLastChild: function () {
return _.last(this.children);
},
bindEvent: function (element, events, handler) {
$(element).on(this.addNamespace(events), handler);
return this;
},
getNext: function () {
var item = this.next;
while (item.isHidden() && this.id !== item.id) { item = item.next; }
return item;
},
getPrev: function () {
var item = this.prev;
while (item.isHidden() && this.id !== item.id) { item = item.prev; }
return item;
},
createElement: function () {
return null;
},
getRoot: function () {
var item = this;
while (item.parent) { item = item.parent; }
return item;
},
populateElement: function () { },
focus: function () {
this.getElement().focus();
this.closeSiblings();
return this;
},
isHidden: function () {
return this.getElement().is(':hidden');
},
getSiblings: function () {
var items = [],
item = this;
while (item.next && item.next.id !== this.id) {
item = item.next;
items.push(item);
}
return items;
},
select: function () { },
unselect: function () { },
setLabel: function () { },
itemHandler: function () { },
keyDownHandler: function () { },
delegateEvents: function () { },
undelegateEvents: function () {
this.getElement().off('.' + this.id);
},
addNamespace: function (events) {
return _.map(events.split(/\s+/), function (event) {
return event + '.' + this.id;
}, this).join(' ');
}
});
AbstractMenu = AbstractItem.extend({
delegateEvents: function () {
this.bindEvent(this.getElement(), 'keydown mouseleave mouseover', this.itemHandler.bind(this))
.bindEvent(this.getElement(), 'contextmenu', function (event) { event.preventDefault(); });
return this;
},
populateElement: function () {
var fragment = document.createDocumentFragment();
_.each(this.getChildren(), function (child) {
fragment.appendChild(child.populateElement()[0]);
}, this);
this.appendContent([fragment]);
this.isRendered = true;
return this.getElement();
},
close: function () {
this.closeChildren();
this.getElement().removeClass('is-opened');
return this;
},
closeChildren: function () {
_.invoke(this.getChildren(), 'close');
return this;
},
itemHandler: function (event) {
event.preventDefault();
var item = $(event.target).data('menu');
switch(event.type) {
case 'keydown':
this.keyDownHandler.call(this, event, item);
break;
case 'mouseover':
this.mouseOverHandler.call(this, event, item);
break;
case 'mouseleave':
this.mouseLeaveHandler.call(this, event, item);
break;
}
},
keyDownHandler: function () { },
mouseOverHandler: function () { },
mouseLeaveHandler: function () { }
});
Menu = AbstractMenu.extend({
initialize: function (options, contextmenuElement, container) {
this.contextmenuElement = $(contextmenuElement);
this.container = $(container);
this.overlay = this.getOverlay();
AbstractMenu.prototype.initialize.apply(this, arguments);
this.build(this, this.options.items);
},
createElement: function () {
return $('<ol />', {
'class': ['contextmenu', this.options.prefix + 'contextmenu'].join(' '),
'role': 'menu',
'tabindex': -1
});
},
delegateEvents: function () {
AbstractMenu.prototype.delegateEvents.call(this);
this.bindEvent(this.contextmenuElement, 'contextmenu', this.contextmenuHandler.bind(this))
.bindEvent(window, 'resize', _.debounce(this.close.bind(this), 100));
return this;
},
destroy: function () {
AbstractMenu.prototype.destroy.call(this);
this.overlay.destroy();
this.contextmenuElement.removeData('contextmenu');
return this;
},
undelegateEvents: function () {
AbstractMenu.prototype.undelegateEvents.call(this);
this.contextmenuElement.off(this.addNamespace('contextmenu'));
this.overlay.undelegateEvents();
return this;
},
appendContent: function (content) {
this.getElement().append(content);
return this;
},
addChild: function () {
AbstractMenu.prototype.addChild.apply(this, arguments);
this.next = this.getFirstChild();
this.prev = this.getLastChild();
return this;
},
build: function (container, items) {
_.each(items, function(item) {
var child;
if (_.has(item, 'items')) {
child = this.build((new Submenu(item, this.contextmenuElement)), item.items);
} else {
child = new MenuItem(item);
}
container.addChild(child);
}, this);
return container;
},
focus: function () {
this.getElement().focus();
return this;
},
open: function () {
var menu = (this.isRendered) ? this.getElement() : this.populateElement();
this.container.append(menu);
AbstractItem.prototype.open.call(this);
this.overlay.show(this.container);
return this;
},
close: function () {
AbstractMenu.prototype.close.call(this);
this.getElement().detach();
this.overlay.hide();
return this;
},
position: function(event) {
this.getElement().position({
my: 'left top',
of: event,
collision: 'flipfit flipfit',
within: this.contextmenuElement
});
return this;
},
pointInContainerBox: function (x, y) {
var containerOffset = this.contextmenuElement.offset(),
containerBox = {
x0: containerOffset.left,
y0: containerOffset.top,
x1: containerOffset.left + this.contextmenuElement.outerWidth(),
y1: containerOffset.top + this.contextmenuElement.outerHeight()
};
return containerBox.x0 <= x && x <= containerBox.x1 && containerBox.y0 <= y && y <= containerBox.y1;
},
getOverlay: function () {
return new Overlay(
this.close.bind(this),
function (event) {
event.preventDefault();
if (this.pointInContainerBox(event.pageX, event.pageY)) {
this.position(event).focus();
this.closeChildren();
} else {
this.close();
}
}.bind(this)
);
},
contextmenuHandler: function (event) {
event.preventDefault();
event.stopPropagation();
this.open().position(event).focus();
},
keyDownHandler: function (event, item) {
var KEY = $.ui.keyCode,
keyCode = event.keyCode;
switch (keyCode) {
case KEY.UP:
item.getPrev().focus();
event.stopPropagation();
break;
case KEY.DOWN:
item.getNext().focus();
event.stopPropagation();
break;
case KEY.TAB:
event.stopPropagation();
break;
case KEY.ESCAPE:
this.close();
break;
}
return false;
    }
});
Overlay = Component.extend({
ns: '.overlay',
initialize: function (clickHandler, contextmenuHandler) {
this.element = $('<div />', {
'class': 'overlay'
});
this.clickHandler = clickHandler;
this.contextmenuHandler = contextmenuHandler;
},
destroy: function () {
this.getElement().remove();
this.undelegateEvents();
},
getElement: function () {
return this.element;
},
hide: function () {
this.getElement().detach();
this.undelegateEvents();
return this;
},
show: function (container) {
$(container).append(this.getElement());
this.delegateEvents();
return this;
},
delegateEvents: function () {
var self = this;
$(document)
.on('click' + this.ns, function () {
if (_.isFunction(self.clickHandler)) {
self.clickHandler.apply(this, arguments);
}
self.hide();
})
.on('contextmenu' + this.ns, function () {
if (_.isFunction(self.contextmenuHandler)) {
self.contextmenuHandler.apply(this, arguments);
}
});
return this;
},
undelegateEvents: function () {
$(document).off(this.ns);
return this;
}
});
Submenu = AbstractMenu.extend({
initialize: function (options, contextmenuElement) {
this.contextmenuElement = contextmenuElement;
AbstractMenu.prototype.initialize.apply(this, arguments);
},
createElement: function () {
var element = $('<li />', {
'class': ['submenu-item','menu-item', this.options.prefix + 'submenu-item'].join(' '),
'aria-expanded': 'false',
'aria-haspopup': 'true',
'aria-labelledby': 'submenu-item-label-' + this.id,
'role': 'menuitem',
'tabindex': -1
});
this.label = $('<span />', {
'id': 'submenu-item-label-' + this.id,
'text': this.options.label
}).appendTo(element);
this.list = $('<ol />', {
'class': ['submenu', this.options.prefix + 'submenu'].join(' '),
'role': 'menu'
}).appendTo(element);
return element;
},
appendContent: function (content) {
this.list.append(content);
return this;
},
setLabel: function (label) {
this.label.text(label);
return this;
},
openKeyboard: function () {
if (this.hasChildren()) {
this.open();
this.getFirstChild().focus();
}
return this;
},
keyDownHandler: function (event) {
var KEY = $.ui.keyCode,
keyCode = event.keyCode;
switch (keyCode) {
case KEY.LEFT:
this.close().focus();
event.stopPropagation();
break;
case KEY.RIGHT:
case KEY.ENTER:
case KEY.SPACE:
this.openKeyboard();
event.stopPropagation();
break;
}
return false;
    },
open: function () {
AbstractMenu.prototype.open.call(this);
this.getElement().attr({'aria-expanded': 'true'});
this.position();
return this;
},
close: function () {
AbstractMenu.prototype.close.call(this);
this.getElement().attr({'aria-expanded': 'false'});
return this;
},
position: function () {
this.list.position({
my: 'left top',
at: 'right top',
of: this.getElement(),
collision: 'flipfit flipfit',
within: this.contextmenuElement
});
return this;
},
mouseOverHandler: function () {
clearTimeout(this.timer);
this.timer = setTimeout(this.open.bind(this), 200);
this.focus();
},
mouseLeaveHandler: function () {
clearTimeout(this.timer);
this.timer = setTimeout(this.close.bind(this), 200);
this.focus();
}
});
MenuItem = AbstractItem.extend({
createElement: function () {
var classNames = [
'menu-item', this.options.prefix + 'menu-item',
this.options.isSelected ? 'is-selected' : ''
].join(' ');
return $('<li />', {
'class': classNames,
'aria-selected': this.options.isSelected ? 'true' : 'false',
'role': 'menuitem',
'tabindex': -1,
'text': this.options.label
});
},
populateElement: function () {
return this.getElement();
},
delegateEvents: function () {
this.bindEvent(this.getElement(), 'click keydown contextmenu mouseover', this.itemHandler.bind(this));
return this;
},
setLabel: function (label) {
this.getElement().text(label);
return this;
},
select: function (event) {
this.options.callback.call(this, event, this, this.options);
this.getElement()
.addClass('is-selected')
.attr({'aria-selected': 'true'});
_.invoke(this.getSiblings(), 'unselect');
// Hide the menu.
this.getRoot().close();
return this;
},
unselect: function () {
this.getElement()
.removeClass('is-selected')
.attr({'aria-selected': 'false'});
return this;
},
itemHandler: function (event) {
event.preventDefault();
switch(event.type) {
case 'contextmenu':
case 'click':
this.select();
break;
case 'mouseover':
this.focus();
event.stopPropagation();
break;
case 'keydown':
this.keyDownHandler.call(this, event, this);
break;
}
},
keyDownHandler: function (event) {
var KEY = $.ui.keyCode,
keyCode = event.keyCode;
switch (keyCode) {
case KEY.RIGHT:
event.stopPropagation();
break;
case KEY.ENTER:
case KEY.SPACE:
this.select();
event.stopPropagation();
break;
}
return false;
    }
});
// VideoContextMenu() function - what this module 'exports'.
return function (state, i18n) {
var speedCallback = function (event, menuitem, options) {
var speed = parseFloat(options.label);
state.videoCommands.execute('speed', speed);
},
options = {
items: [{
label: i18n['Play'], // jshint ignore:line
callback: function () {
state.videoCommands.execute('togglePlayback');
},
initialize: function (menuitem) {
state.el.on({
'play': function () {
menuitem.setLabel(i18n['Pause']); // jshint ignore:line
},
'pause': function () {
menuitem.setLabel(i18n['Play']); // jshint ignore:line
}
});
}
}, {
label: state.videoVolumeControl.getMuteStatus() ? i18n['Unmute'] : i18n['Mute'], // jshint ignore:line
callback: function () {
state.videoCommands.execute('toggleMute');
},
initialize: function (menuitem) {
state.el.on({
'volumechange': function () {
if (state.videoVolumeControl.getMuteStatus()) {
menuitem.setLabel(i18n['Unmute']); // jshint ignore:line
} else {
menuitem.setLabel(i18n['Mute']); // jshint ignore:line
}
}
});
}
}, {
label: i18n['Fill browser'],
callback: function () {
state.videoCommands.execute('toggleFullScreen');
},
initialize: function (menuitem) {
state.el.on({
'fullscreen': function (event, isFullscreen) {
if (isFullscreen) {
menuitem.setLabel(i18n['Exit full browser']);
} else {
menuitem.setLabel(i18n['Fill browser']);
}
}
});
}
}, {
label: i18n['Speed'], // jshint ignore:line
items: _.map(state.speeds, function (speed) {
var isSelected = speed === state.speed;
return {label: speed + 'x', callback: speedCallback, speed: speed, isSelected: isSelected};
}),
initialize: function (menuitem) {
state.el.on({
'speedchange': function (event, speed) {
var item = menuitem.getChildren().filter(function (item) {
return item.options.speed === speed;
})[0];
if (item) {
item.select();
}
}
});
}
}
]
};
$.fn.contextmenu = function (container, options) {
return this.each(function() {
$(this).data('contextmenu', new Menu(options, this, container));
});
};
if (!state.isYoutubeType()) {
state.el.find('video').contextmenu(state.el, options);
}
return $.Deferred().resolve().promise();
};
});
}(RequireJS.define));
(function(define) {
'use strict';
// VideoCommands module.
define('video/10_commands.js', [], function() {
var VideoCommands, Command, playCommand, pauseCommand, togglePlaybackCommand,
muteCommand, unmuteCommand, toggleMuteCommand, toggleFullScreenCommand,
setSpeedCommand;
/**
* Video commands module.
* @exports video/10_commands.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}
*/
VideoCommands = function(state, i18n) {
if (!(this instanceof VideoCommands)) {
return new VideoCommands(state, i18n);
}
this.state = state;
this.state.videoCommands = this;
this.i18n = i18n;
this.commands = [];
this.initialize();
return $.Deferred().resolve().promise();
};
VideoCommands.prototype = {
/** Initializes the module. */
initialize: function() {
this.commands = this.getCommands();
},
execute: function (command) {
var args = [].slice.call(arguments, 1) || [];
if (_.has(this.commands, command)) {
this.commands[command].execute.apply(this, [this.state].concat(args));
} else {
console.log('Command "' + command + '" is not available.');
}
},
getCommands: function () {
var commands = {},
commandsList = [
playCommand, pauseCommand, togglePlaybackCommand,
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand
];
_.each(commandsList, function(command) {
commands[command.name] = command;
}, this);
return commands;
}
};
Command = function (name, execute) {
this.name = name;
this.execute = execute;
};
playCommand = new Command('play', function (state) {
state.videoPlayer.play();
});
pauseCommand = new Command('pause', function (state) {
state.videoPlayer.pause();
});
togglePlaybackCommand = new Command('togglePlayback', function (state) {
if (state.videoControl.isPlaying) {
pauseCommand.execute(state);
} else {
playCommand.execute(state);
}
});
toggleMuteCommand = new Command('toggleMute', function (state) {
state.videoVolumeControl.toggleMute();
});
toggleFullScreenCommand = new Command('toggleFullScreen', function (state) {
state.videoControl.toggleFullScreen();
});
setSpeedCommand = new Command('speed', function (state, speed) {
state.videoSpeedControl.setSpeed(state.speedToString(speed));
});
return VideoCommands;
});
}(RequireJS.define));
......@@ -43,7 +43,9 @@
'video/06_video_progress_slider.js',
'video/07_video_volume_control.js',
'video/08_video_speed_control.js',
'video/09_video_caption.js'
'video/09_video_caption.js',
'video/10_commands.js',
'video/095_video_context_menu.js'
],
function (
initialize,
......@@ -54,7 +56,9 @@
VideoProgressSlider,
VideoVolumeControl,
VideoSpeedControl,
VideoCaption
VideoCaption,
VideoCommands,
VideoContextMenu
) {
var youtubeXhr = null,
oldVideo = window.Video;
......@@ -87,7 +91,9 @@
VideoProgressSlider,
VideoVolumeControl,
VideoSpeedControl,
VideoCaption
VideoCaption,
VideoCommands,
VideoContextMenu
];
state.youtubeXhr = youtubeXhr;
......
......@@ -67,6 +67,7 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
module = __name__.replace('.video_module', '', 2)
js = {
'js': [
resource_string(module, 'js/src/video/00_component.js'),
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'),
......@@ -84,6 +85,8 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
resource_string(module, 'js/src/video/07_video_volume_control.js'),
resource_string(module, 'js/src/video/08_video_speed_control.js'),
resource_string(module, 'js/src/video/09_video_caption.js'),
resource_string(module, 'js/src/video/095_video_context_menu.js'),
resource_string(module, 'js/src/video/10_commands.js'),
resource_string(module, 'js/src/video/10_main.js')
]
}
......@@ -93,7 +96,6 @@ class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
]}
js_module_name = "Video"
def get_html(self):
track_url = None
download_video_link = None
......
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