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
This commit is contained in:
Adam Butterworth
2020-01-24 09:48:41 -05:00
committed by GitHub
parent 2b1134d7e1
commit f0aa3daa87
8 changed files with 213 additions and 127 deletions

View File

@@ -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);

View File

@@ -173,6 +173,7 @@
describe('when video is right-clicked', function() {
beforeEach(function() {
state = jasmine.initializePlayer();
jasmine.mockFullscreenAPI();
openMenu();
});

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -11,82 +11,133 @@
'</button>'
].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));

View File

@@ -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

View File

@@ -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

12
package-lock.json generated
View File

@@ -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",