diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index 1c8b3ad5eedfcbc93c271e72536fb5ef394d988e..98418008ee5075e840513414d4147945c699f226 100644 --- a/cms/static/sass/elements/_xmodules.scss +++ b/cms/static/sass/elements/_xmodules.scss @@ -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 { diff --git a/common/lib/xmodule/xmodule/css/video/accessible_menu.scss b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss index f7b868b1070b16b9530966885beb8dcbfa79892b..5cb2de0d41edafbeb11b8df3b8f567e3e19e1bfc 100644 --- a/common/lib/xmodule/xmodule/css/video/accessible_menu.scss +++ b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss @@ -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; +} diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index e6140f46c297616bf31e920eb856f5b64e152c95..a6c5cf74a56211da31adcbc10f25d7047054c13c 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -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 { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c779b9888d65ac24977460e5036be137aaa606f6 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/video/video_context_menu_spec.js @@ -0,0 +1,441 @@ +(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'); + }); + }); + }); +})(); diff --git a/common/lib/xmodule/xmodule/js/src/video/00_component.js b/common/lib/xmodule/xmodule/js/src/video/00_component.js new file mode 100644 index 0000000000000000000000000000000000000000..5c3e1d10042159e7d5bea645537aadfb194e1851 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/00_component.js @@ -0,0 +1,80 @@ +(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)); diff --git a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js index fda1ada13c8c52589516e59e84984595ae4c5fd4..ba9f1f27db07478eab9024887e606c87cbbc6e7e 100644 --- a/common/lib/xmodule/xmodule/js/src/video/00_i18n.js +++ b/common/lib/xmodule/xmodule/js/src/video/00_i18n.js @@ -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'), diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js index 4d2b3adcd9498c26bca66f9cd2582987a80dc66c..084afc69323618bd6d19758b0fadc62157680be9 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_control.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_control.js @@ -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'); } } 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 7467574024f673d2d6b9ff3831af897a278d209a..8f4b95d36df60af62395d5cc7912fd1766c76bfc 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 @@ -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; }, diff --git a/common/lib/xmodule/xmodule/js/src/video/095_video_context_menu.js b/common/lib/xmodule/xmodule/js/src/video/095_video_context_menu.js new file mode 100644 index 0000000000000000000000000000000000000000..33fbfa752bd77f625c76fcad8858e7edd93ba023 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/095_video_context_menu.js @@ -0,0 +1,665 @@ +(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)); diff --git a/common/lib/xmodule/xmodule/js/src/video/10_commands.js b/common/lib/xmodule/xmodule/js/src/video/10_commands.js new file mode 100644 index 0000000000000000000000000000000000000000..ad78066864d34e5924b11c27069cb5cf19d54ee4 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/video/10_commands.js @@ -0,0 +1,97 @@ +(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)); diff --git a/common/lib/xmodule/xmodule/js/src/video/10_main.js b/common/lib/xmodule/xmodule/js/src/video/10_main.js index 5e8d9f281176cb3ff631fba7639c7a884f125968..1ac539111726776ab91fd82806e5d1a9db44ad03 100644 --- a/common/lib/xmodule/xmodule/js/src/video/10_main.js +++ b/common/lib/xmodule/xmodule/js/src/video/10_main.js @@ -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; diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index 26e23207c93a4e86befc04a0cd59fc6471c1c700..51d0042101b6cbd9a24dc293f1b789070f2f7618 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -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