diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index 1c8b3ad5ee..98418008ee 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 f7b868b107..5cb2de0d41 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 e6140f46c2..a6c5cf74a5 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 0000000000..c779b9888d --- /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 0000000000..5c3e1d1004 --- /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 fda1ada13c..ba9f1f27db 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 4d2b3adcd9..084afc6932 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 7467574024..8f4b95d36d 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 0000000000..33fbfa752b --- /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 $('