From f0aa3daa872da0c4bb712e755ecfee0c6801bacd Mon Sep 17 00:00:00 2001 From: Adam Butterworth Date: Fri, 24 Jan 2020 09:48:41 -0500 Subject: [PATCH] Use Fullscreen API for video XBlock full screen mode (#22896) [TNL-7051] Clicking a video XBlock's fullscreen button now takes the video fullscreen instead of full window. Gracefully fallback to full window if fullscreen apis are absent --- common/lib/xmodule/xmodule/js/spec/helper.js | 32 +++ .../js/spec/video/video_context_menu_spec.js | 1 + .../js/spec/video/video_full_screen_spec.js | 8 +- .../js/spec/video/video_player_spec.js | 7 +- .../js/src/video/04_video_full_screen.js | 231 ++++++++++++------ .../tests/video/test_video_module.py | 47 ---- docs/guides/testing/testing.rst | 2 + package-lock.json | 12 +- 8 files changed, 213 insertions(+), 127 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/helper.js b/common/lib/xmodule/xmodule/js/spec/helper.js index 71ca1df6a3..19c614217e 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.js +++ b/common/lib/xmodule/xmodule/js/spec/helper.js @@ -266,4 +266,36 @@ this.description = description; this.specDefinitions = specDefinitions; }; + + // This HTML Fullscreen API mock should use promises or async functions + // as the spec defines. We do not use them here because we're locked + // in to a version of jasmine that doesn't fully support async functions + // or promises. This mock also assumes that if non-vendor prefixed methods + // and properties are missing, then we'll use mozilla prefixed names since + // automated tests happen in firefox. + jasmine.mockFullscreenAPI = function() { + var fullscreenElement; + var vendorChangeEvent = 'fullscreenEnabled' in document ? + 'fullscreenchange' : 'mozfullscreenchange'; + var vendorRequestFullscreen = 'requestFullscreen' in window.HTMLElement.prototype ? + 'requestFullscreen' : 'mozRequestFullScreen'; + var vendorExitFullscreen = 'exitFullscreen' in document ? + 'exitFullscreen' : 'mozCancelFullScreen'; + var vendorFullscreenElement = 'fullscreenEnabled' in document ? + 'fullscreenElement' : 'mozFullScreenElement'; + + spyOn(window.HTMLElement.prototype, vendorRequestFullscreen).and.callFake(function() { + fullscreenElement = this; + document.dispatchEvent(new Event(vendorChangeEvent)); + }); + + spyOn(document, vendorExitFullscreen).and.callFake(function() { + fullscreenElement = null; + document.dispatchEvent(new Event(vendorChangeEvent)); + }); + + spyOnProperty(document, vendorFullscreenElement).and.callFake(function() { + return fullscreenElement; + }); + }; }).call(this); 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 index eb2d414702..f694ec440f 100644 --- 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 @@ -173,6 +173,7 @@ describe('when video is right-clicked', function() { beforeEach(function() { state = jasmine.initializePlayer(); + jasmine.mockFullscreenAPI(); openMenu(); }); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js index 0308f92c30..476f63fe46 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_full_screen_spec.js @@ -19,6 +19,7 @@ describe('constructor', function() { beforeEach(function() { state = jasmine.initializePlayer(); + jasmine.mockFullscreenAPI(); }); it('renders the fullscreen control', function() { @@ -69,9 +70,9 @@ }); it('can update video dimensions on state change', function() { - state.el.trigger('fullscreen', [true]); + state.videoFullScreen.enter(); expect(state.resizer.setMode).toHaveBeenCalledWith('both'); - state.el.trigger('fullscreen', [false]); + state.videoFullScreen.exit(); expect(state.resizer.setMode).toHaveBeenCalledWith('width'); }); @@ -88,9 +89,10 @@ }); state = jasmine.initializePlayer(); - $(state.el).trigger('fullscreen'); + state.videoFullScreen.enter(); expect(state.videoFullScreen.height).toBe(150); + state.videoFullScreen.exit(); }); }); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 86c1d5f60f..498e82de04 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -714,6 +714,7 @@ function(VideoPlayer, HLS, _) { describe('when the video player is not full screen', function() { beforeEach(function() { state = jasmine.initializePlayer(); + jasmine.mockFullscreenAPI(); state.videoEl = $('video, iframe'); spyOn($.fn, 'trigger').and.callThrough(); $('.add-fullscreen').click(); @@ -733,12 +734,10 @@ function(VideoPlayer, HLS, _) { describe('when the video player already full screen', function() { beforeEach(function() { state = jasmine.initializePlayer(); + jasmine.mockFullscreenAPI(); state.videoEl = $('video, iframe'); spyOn($.fn, 'trigger').and.callThrough(); - state.el.addClass('video-fullscreen'); - state.videoFullScreen.fullScreenState = true; - state.videoFullScreen.isFullScreen = true; - state.videoFullScreen.fullScreenEl.attr('title', 'Exit-fullscreen'); + state.videoFullScreen.enter(); $('.add-fullscreen').click(); }); diff --git a/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js b/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js index b734d8d40d..c8d1741e19 100644 --- a/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js +++ b/common/lib/xmodule/xmodule/js/src/video/04_video_full_screen.js @@ -11,82 +11,133 @@ '' ].join(''); - // VideoControl() function - what this module "exports". - return function(state) { - var dfd = $.Deferred(); + // The following properties and functions enable cross-browser use of the + // the Fullscreen Web API. + // + // function getVendorPrefixed(property) + // function getFullscreenElement() + // function exitFullscreen() + // function requestFullscreen(element, options) + // + // For more information about the Fullscreen Web API see MDN: + // https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API + var prefixedFullscreenProperties = (function() { + if ('fullscreenEnabled' in document) { + return { + fullscreenElement: 'fullscreenElement', + fullscreenEnabled: 'fullscreenEnabled', + requestFullscreen: 'requestFullscreen', + exitFullscreen: 'exitFullscreen', + fullscreenchange: 'fullscreenchange', + fullscreenerror: 'fullscreenerror' + }; + } + if ('webkitFullscreenEnabled' in document) { + return { + fullscreenElement: 'webkitFullscreenElement', + fullscreenEnabled: 'webkitFullscreenEnabled', + requestFullscreen: 'webkitRequestFullscreen', + exitFullscreen: 'webkitExitFullscreen', + fullscreenchange: 'webkitfullscreenchange', + fullscreenerror: 'webkitfullscreenerror' + }; + } + if ('mozFullScreenEnabled' in document) { + return { + fullscreenElement: 'mozFullScreenElement', + fullscreenEnabled: 'mozFullScreenEnabled', + requestFullscreen: 'mozRequestFullScreen', + exitFullscreen: 'mozCancelFullScreen', + fullscreenchange: 'mozfullscreenchange', + fullscreenerror: 'mozfullscreenerror' + }; + } + if ('msFullscreenEnabled' in document) { + return { + fullscreenElement: 'msFullscreenElement', + fullscreenEnabled: 'msFullscreenEnabled', + requestFullscreen: 'msRequestFullscreen', + exitFullscreen: 'msExitFullscreen', + fullscreenchange: 'MSFullscreenChange', + fullscreenerror: 'MSFullscreenError' + }; + } + return {}; + }()); - state.videoFullScreen = {}; + function getVendorPrefixed(property) { + return prefixedFullscreenProperties[property]; + } - _makeFunctionsPublic(state); - _renderElements(state); - _bindHandlers(state); + function getFullscreenElement() { + return document[getVendorPrefixed('fullscreenElement')]; + } - dfd.resolve(); - return dfd.promise(); - }; + function exitFullscreen() { + if (document[getVendorPrefixed('exitFullscreen')]) { + return document[getVendorPrefixed('exitFullscreen')](); + } + return null; + } + + function requestFullscreen(element, options) { + if (element[getVendorPrefixed('requestFullscreen')]) { + return element[getVendorPrefixed('requestFullscreen')](options); + } + return null; + } // *************************************************************** // Private functions start here. // *************************************************************** - // function _makeFunctionsPublic(state) - // - // Functions which will be accessible via 'state' object. When called, these functions will - // get the 'state' object as a context. - function _makeFunctionsPublic(state) { - var methodsDict = { - destroy: destroy, - enter: enter, - exitHandler: exitHandler, - exit: exit, - onFullscreenChange: onFullscreenChange, - toggle: toggle, - toggleHandler: toggleHandler, - updateControlsHeight: updateControlsHeight - }; - - state.bindTo(methodsDict, state.videoFullScreen, state); - } - function destroy() { $(document).off('keyup', this.videoFullScreen.exitHandler); this.videoFullScreen.fullScreenEl.remove(); this.el.off({ - fullscreen: this.videoFullScreen.onFullscreenChange, destroy: this.videoFullScreen.destroy }); + document.removeEventListener( + getVendorPrefixed('fullscreenchange'), + this.videoFullScreen.handleFullscreenChange + ); if (this.isFullScreen) { this.videoFullScreen.exit(); } delete this.videoFullScreen; } - // function _renderElements(state) + // function renderElements(state) // // Create any necessary DOM elements, attach them, and set their initial configuration. Also // make the created DOM elements available via the 'state' object. Much easier to work this // way - you don't have to do repeated jQuery element selects. - function _renderElements(state) { + function renderElements(state) { + /* eslint-disable no-param-reassign */ state.videoFullScreen.fullScreenEl = $(template); state.videoFullScreen.sliderEl = state.el.find('.slider'); state.videoFullScreen.fullScreenState = false; HtmlUtils.append(state.el.find('.secondary-controls'), HtmlUtils.HTML(state.videoFullScreen.fullScreenEl)); state.videoFullScreen.updateControlsHeight(); + /* eslint-enable no-param-reassign */ } - // function _bindHandlers(state) + // function bindHandlers(state) // // Bind any necessary function callbacks to DOM events (click, mousemove, etc.). - function _bindHandlers(state) { + function bindHandlers(state) { state.videoFullScreen.fullScreenEl.on('click', state.videoFullScreen.toggleHandler); state.el.on({ - fullscreen: state.videoFullScreen.onFullscreenChange, destroy: state.videoFullScreen.destroy }); $(document).on('keyup', state.videoFullScreen.exitHandler); + document.addEventListener( + getVendorPrefixed('fullscreenchange'), + state.videoFullScreen.handleFullscreenChange + ); } - function _getControlsHeight(controls, slider) { + function getControlsHeight(controls, slider) { return controls.height() + 0.5 * slider.height(); } @@ -96,26 +147,17 @@ // The magic private function that makes them available and sets up their context is makeFunctionsPublic(). // *************************************************************** - function onFullscreenChange(event, isFullScreen) { - var height = this.videoFullScreen.updateControlsHeight(); - - if (isFullScreen) { - this.resizer - .delta - .substract(height, 'height') - .setMode('both'); - } else { - this.resizer - .delta - .reset() - .setMode('width'); + function handleFullscreenChange() { + if (getFullscreenElement() !== this.el[0] && this.isFullScreen) { + // The video was fullscreen so this event must relate to this video + this.videoFullScreen.handleExit(); } } function updateControlsHeight() { var controls = this.el.find('.video-controls'), slider = this.videoFullScreen.sliderEl; - this.videoFullScreen.height = _getControlsHeight(controls, slider); + this.videoFullScreen.height = getControlsHeight(controls, slider); return this.videoFullScreen.height; } @@ -128,9 +170,13 @@ this.videoCommands.execute('toggleFullScreen'); } - function exit() { - var fullScreenClassNameEl = this.el.add(document.documentElement), - closedCaptionsEl = this.el.find('.closed-captions'); + function handleExit() { + var fullScreenClassNameEl = this.el.add(document.documentElement); + var closedCaptionsEl = this.el.find('.closed-captions'); + + if (this.isFullScreen === false) { + return; + } this.videoFullScreen.fullScreenState = this.isFullScreen = false; fullScreenClassNameEl.removeClass('video-fullscreen'); @@ -141,20 +187,20 @@ .removeClass('fa-compress') .addClass('fa-arrows-alt'); - this.el.trigger('fullscreen', [this.isFullScreen]); + $(closedCaptionsEl).css({top: '70%', left: '5%'}); - $(closedCaptionsEl).css({ - top: '70%', - left: '5%' - }); + this.resizer.delta.reset().setMode('width'); + this.el.trigger('fullscreen', [this.isFullScreen]); } - function enter() { - var fullScreenClassNameEl = this.el.add(document.documentElement), - closedCaptionsEl = this.el.find('.closed-captions'); + function handleEnter() { + var fullScreenClassNameEl = this.el.add(document.documentElement); + var closedCaptionsEl = this.el.find('.closed-captions'); + + if (this.isFullScreen === true) { + return; + } - this.scrollPos = $(window).scrollTop(); - $(window).scrollTop(0); this.videoFullScreen.fullScreenState = this.isFullScreen = true; fullScreenClassNameEl.addClass('video-fullscreen'); this.videoFullScreen.fullScreenEl @@ -163,12 +209,25 @@ .removeClass('fa-arrows-alt') .addClass('fa-compress'); - this.el.trigger('fullscreen', [this.isFullScreen]); + $(closedCaptionsEl).css({top: '70%', left: '5%'}); - $(closedCaptionsEl).css({ - top: '70%', - left: '5%' - }); + this.resizer.delta.substract(this.videoFullScreen.updateControlsHeight(), 'height').setMode('both'); + this.el.trigger('fullscreen', [this.isFullScreen]); + } + + function exit() { + if (getFullscreenElement() === this.el[0]) { + exitFullscreen(); + } else { + // Else some other element is fullscreen or the fullscreen api does not exist. + this.videoFullScreen.handleExit(); + } + } + + function enter() { + this.scrollPos = $(window).scrollTop(); + this.videoFullScreen.handleEnter(); + requestFullscreen(this.el[0]); } /** Toggle fullscreen mode. */ @@ -190,5 +249,41 @@ this.videoCommands.execute('toggleFullScreen'); } } + + // function makeFunctionsPublic(state) + // + // Functions which will be accessible via 'state' object. When called, these functions will + // get the 'state' object as a context. + function makeFunctionsPublic(state) { + var methodsDict = { + destroy: destroy, + enter: enter, + exit: exit, + exitHandler: exitHandler, + handleExit: handleExit, + handleEnter: handleEnter, + handleFullscreenChange: handleFullscreenChange, + toggle: toggle, + toggleHandler: toggleHandler, + updateControlsHeight: updateControlsHeight + }; + + state.bindTo(methodsDict, state.videoFullScreen, state); + } + + // VideoControl() function - what this module "exports". + return function(state) { + var dfd = $.Deferred(); + + // eslint-disable-next-line no-param-reassign + state.videoFullScreen = {}; + + makeFunctionsPublic(state); + renderElements(state); + bindHandlers(state); + + dfd.resolve(); + return dfd.promise(); + }; }); }(RequireJS.define)); diff --git a/common/test/acceptance/tests/video/test_video_module.py b/common/test/acceptance/tests/video/test_video_module.py index c0ac07e43c..d868201527 100644 --- a/common/test/acceptance/tests/video/test_video_module.py +++ b/common/test/acceptance/tests/video/test_video_module.py @@ -302,21 +302,6 @@ class YouTubeVideoTest(VideoBaseTest): self.navigate_to_video() self.assertFalse(self.video.is_button_shown('transcript_button')) - def test_fullscreen_video_alignment_with_transcript_hidden(self): - """ - Scenario: Video is aligned with transcript hidden in fullscreen mode - Given the course has a Video component in "Youtube" mode - When I view the video at fullscreen - Then the video with the transcript hidden is aligned correctly - """ - self.navigate_to_video() - - # click video button "fullscreen" - self.video.click_player_button('fullscreen') - - # check if video aligned correctly without enabled transcript - self.assertTrue(self.video.is_aligned(False)) - def test_download_button_wo_english_transcript(self): """ Scenario: Download button works correctly w/o english transcript in YouTube mode @@ -371,38 +356,6 @@ class YouTubeVideoTest(VideoBaseTest): unicode_text = u"好 各位同学" self.assertTrue(self.video.downloaded_transcript_contains_text('srt', unicode_text)) - def test_fullscreen_video_alignment_on_transcript_toggle(self): - """ - Scenario: Video is aligned correctly on transcript toggle in fullscreen mode - Given the course has a Video component in "Youtube" mode - And I have uploaded a .srt.sjson file to assets - And I have defined subtitles for the video - When I view the video at fullscreen - Then the video with the transcript enabled is aligned correctly - And the video with the transcript hidden is aligned correctly - """ - self.assets.append('subs_3_yD_cEKoCk.srt.sjson') - data = {'sub': '3_yD_cEKoCk'} - self.metadata = self.metadata_for_mode('youtube', additional_data=data) - - # go to video - self.navigate_to_video() - - # make sure captions are opened - self.video.show_captions() - - # click video button "fullscreen" - self.video.click_player_button('fullscreen') - - # check if video aligned correctly with enabled transcript - self.assertTrue(self.video.is_aligned(True)) - - # click video button "transcript" - self.video.click_player_button('transcript_button') - - # check if video aligned correctly without enabled transcript - self.assertTrue(self.video.is_aligned(False)) - def test_video_rendering_with_default_response_time(self): """ Scenario: Video is rendered in Youtube mode when the YouTube Server responds quickly diff --git a/docs/guides/testing/testing.rst b/docs/guides/testing/testing.rst index 155a92626b..6af957fe68 100644 --- a/docs/guides/testing/testing.rst +++ b/docs/guides/testing/testing.rst @@ -350,6 +350,7 @@ console, run these commands:: paver test_js_run -s cms paver test_js_run -s cms-squire paver test_js_run -s xmodule + paver test_js_run -s xmodule-webpack paver test_js_run -s common paver test_js_run -s common-requirejs @@ -359,6 +360,7 @@ To run JavaScript tests in a browser, run these commands:: paver test_js_dev -s cms paver test_js_dev -s cms-squire paver test_js_dev -s xmodule + paver test_js_dev -s xmodule-webpack paver test_js_dev -s common paver test_js_dev -s common-requirejs diff --git a/package-lock.json b/package-lock.json index 9b1680f36a..23cc0855df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1824,6 +1824,13 @@ "babel-runtime": "^6.26.0", "core-js": "^2.5.0", "regenerator-runtime": "^0.10.5" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=" + } } }, "babel-preset-env": { @@ -11639,11 +11646,6 @@ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz", "integrity": "sha512-jVpo1GadrDAK59t/0jRx5VxYWQEDkkEKi6+HjE3joFVLfDOh9Xrdh0dF1eSq+BI/SwvTQ44gSscJ8N5zYL61sg==" }, - "regenerator-runtime": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", - "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=" - }, "regenerator-transform": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz",