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:
@@ -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);
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
describe('when video is right-clicked', function() {
|
||||
beforeEach(function() {
|
||||
state = jasmine.initializePlayer();
|
||||
jasmine.mockFullscreenAPI();
|
||||
openMenu();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user