Commit 005e313f by Valera Rozuvan

Merge pull request #1677 from edx/valera/alert_screenreader_video_end

a11y - Videos need to alert screenreaders when the video is over.
parents f0bbd34a 6910ac95
...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Blades: a11y - Videos will alert screenreaders when the video is over.
LMS: Trap focus on the loading element when a user loads more threads LMS: Trap focus on the loading element when a user loads more threads
in the forum sidebar to improve accessibility. in the forum sidebar to improve accessibility.
......
...@@ -72,7 +72,17 @@ ...@@ -72,7 +72,17 @@
expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled(); expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled();
}); });
it('after controls hide focus grabbers are enabled', function () { // Disabled on 18.11.2013 due to flakiness on local dev machine.
//
// Video FocusGrabber: after controls hide focus grabbers are
// enabled [fail]
// Expected spy enableFocusGrabber to have been called.
//
// Approximately 1 in 8 times this test fails.
//
// TODO: Most likely, focusGrabber will be disabled in the future. This
// test could become unneeded in the future.
xit('after controls hide focus grabbers are enabled', function () {
runs(function () { runs(function () {
// Captions should not be "sticky" for the autohide mechanism // Captions should not be "sticky" for the autohide mechanism
// to work. // to work.
......
...@@ -4,11 +4,21 @@ ...@@ -4,11 +4,21 @@
videoProgressSlider, videoSpeedControl, videoVolumeControl, videoProgressSlider, videoSpeedControl, videoVolumeControl,
oldOTBD; oldOTBD;
function initialize(fixture) { function initialize(fixture, params) {
if (typeof fixture === 'undefined') { if (_.isString(fixture)) {
loadFixtures('video_all.html');
} else {
loadFixtures(fixture); loadFixtures(fixture);
} else {
if (_.isObject(fixture)) {
params = fixture;
}
loadFixtures('video_all.html');
}
if (_.isObject(params)) {
$('#example')
.find('#video_id')
.data(params);
} }
state = new Video('#example'); state = new Video('#example');
...@@ -532,8 +542,54 @@ ...@@ -532,8 +542,54 @@
}); });
}); });
// Disabled 10/24/13 due to flakiness in master describe('update with start & end time', function () {
xdescribe('updatePlayTime', function () { var START_TIME = 1, END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'update').andCallThrough();
spyOn(videoPlayer, 'pause').andCallThrough();
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('video is paused on first endTime, start & end time are reset', function () {
var checkForStartEndTimeSet = true;
videoProgressSlider.notifyThroughHandleEnd.reset();
videoPlayer.pause.reset();
videoPlayer.play();
waitsFor(function () {
if (
!isFinite(videoPlayer.currentTime) ||
videoPlayer.currentTime <= 0
) {
return false;
}
if (checkForStartEndTimeSet) {
checkForStartEndTimeSet = false;
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
}
return videoPlayer.pause.calls.length === 1
}, 5000, 'pause() has been called');
runs(function () {
expect(videoPlayer.startTime).toBe(0);
expect(videoPlayer.endTime).toBe(null);
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: true});
});
});
});
describe('updatePlayTime', function () {
beforeEach(function () { beforeEach(function () {
initialize(); initialize();
...@@ -548,7 +604,7 @@ ...@@ -548,7 +604,7 @@
duration = videoPlayer.duration(); duration = videoPlayer.duration();
if (duration > 0) { if (duration > 0) {
return true; return true;
} }
return false; return false;
...@@ -612,6 +668,74 @@ ...@@ -612,6 +668,74 @@
}); });
}); });
describe('updatePlayTime when start & end times are defined', function () {
var START_TIME = 1,
END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
spyOn(videoPlayer.player, 'seekTo').andCallThrough();
spyOn(videoProgressSlider, 'updateStartEndTimeRegion')
.andCallThrough();
});
it('when duration becomes available, updatePlayTime() is called', function () {
var duration;
expect(videoPlayer.initialSeekToStartTime).toBeTruthy();
expect(videoPlayer.seekToStartTimeOldSpeed).toBe('void');
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'duration becomes available', 1000);
runs(function () {
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
expect(videoPlayer.player.seekTo).toHaveBeenCalledWith(START_TIME);
expect(videoProgressSlider.updateStartEndTimeRegion)
.toHaveBeenCalledWith({duration: duration});
expect(videoPlayer.seekToStartTimeOldSpeed).toBe(state.speed);
});
});
});
describe('updatePlayTime with invalid endTime', function () {
beforeEach(function () {
initialize({end: 100000});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
});
it('invalid endTime is reset to null', function () {
var duration;
videoPlayer.updatePlayTime.reset();
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'updatePlayTime was invoked and duration is set', 5000);
runs(function () {
expect(videoPlayer.endTime).toBe(null);
});
});
});
describe('toggleFullScreen', function () { describe('toggleFullScreen', function () {
describe('when the video player is not full screen', function () { describe('when the video player is not full screen', function () {
beforeEach(function () { beforeEach(function () {
......
...@@ -285,6 +285,55 @@ ...@@ -285,6 +285,55 @@
expect(params).toEqual(expectedParams); expect(params).toEqual(expectedParams);
}); });
}); });
describe('notifyThroughHandleEnd', function () {
beforeEach(function () {
initialize();
spyOnEvent(videoProgressSlider.handle, 'focus');
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('params.end = true', function () {
videoProgressSlider.notifyThroughHandleEnd({end: true});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video ended');
expect('focus').toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('params.end = false', function () {
videoProgressSlider.notifyThroughHandleEnd({end: false});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video position');
expect('focus').not.toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('is called when video plays', function () {
videoPlayer.play();
waitsFor(function () {
var duration = videoPlayer.duration(),
currentTime = videoPlayer.currentTime;
return (
isFinite(duration) &&
duration > 0 &&
isFinite(currentTime) &&
currentTime > 0
);
}, 'duration is set, video is playing', 5000);
runs(function () {
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: false});
});
});
});
}); });
}).call(this); }).call(this);
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// VideoPlayer module. // VideoPlayer module.
define( define(
'video/03_video_player.js', 'video/03_video_player.js',
['video/02_html5_video.js', 'video/00_resizer.js' ], ['video/02_html5_video.js', 'video/00_resizer.js'],
function (HTML5Video, Resizer) { function (HTML5Video, Resizer) {
var dfd = $.Deferred(); var dfd = $.Deferred();
...@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) { ...@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) {
state.videoPlayer.initialSeekToStartTime = true; state.videoPlayer.initialSeekToStartTime = true;
state.videoPlayer.oneTimePauseAtEndTime = true; // At the start, the initial value of the variable
// `seekToStartTimeOldSpeed` should always differ from the value
// The initial value of the variable `seekToStartTimeOldSpeed` // returned by the duration function.
// should always differ from the value returned by the duration
// function.
state.videoPlayer.seekToStartTimeOldSpeed = 'void'; state.videoPlayer.seekToStartTimeOldSpeed = 'void';
state.videoPlayer.playerVars = { state.videoPlayer.playerVars = {
...@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) { ...@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) {
// This function gets the video's current play position in time // This function gets the video's current play position in time
// (currentTime) and its duration. // (currentTime) and its duration.
// It is called at a regular interval when the video is playing (see // It is called at a regular interval when the video is playing.
// below).
function update() { function update() {
this.videoPlayer.currentTime = this.videoPlayer.player this.videoPlayer.currentTime = this.videoPlayer.player
.getCurrentTime(); .getCurrentTime();
...@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) { ...@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) {
if (isFinite(this.videoPlayer.currentTime)) { if (isFinite(this.videoPlayer.currentTime)) {
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime); this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
// We need to pause the video is current time is smaller (or equal) // We need to pause the video if current time is smaller (or equal)
// than end time. Also, we must make sure that the end time is the // than end time. Also, we must make sure that this is only done
// one that was set in the configuration parameter. If it differs, // once.
// this means that it was either reset to the end, or the duration
// changed it's value.
// //
// In the case of YouTube Flash mode, we must remember that the // If `endTime` is not `null`, then we are safe to pause the
// start and end times are rescaled based on the current speed of // video. `endTime` will be set to `null`, and this if statement
// the video. // will not be executed on next runs.
if ( if (
this.videoPlayer.endTime <= this.videoPlayer.currentTime && this.videoPlayer.endTime != null &&
this.videoPlayer.oneTimePauseAtEndTime this.videoPlayer.endTime <= this.videoPlayer.currentTime
) { ) {
this.videoPlayer.oneTimePauseAtEndTime = false;
this.videoPlayer.pause(); this.videoPlayer.pause();
this.videoPlayer.endTime = this.videoPlayer.duration();
// After the first time the video reached the `endTime`,
// `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
} }
} }
} }
...@@ -321,8 +324,10 @@ function (HTML5Video, Resizer) { ...@@ -321,8 +324,10 @@ function (HTML5Video, Resizer) {
} }
); );
// After the user seeks, startTime and endTime are disabled. The video
// will play from start to the end on subsequent runs.
this.videoPlayer.startTime = 0; this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = duration; this.videoPlayer.endTime = null;
this.videoPlayer.player.seekTo(newTime, true); this.videoPlayer.player.seekTo(newTime, true);
...@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) { ...@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) {
var time = this.videoPlayer.duration(); var time = this.videoPlayer.duration();
this.trigger('videoControl.pause', null); this.trigger('videoControl.pause', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
if (this.config.show_captions) { if (this.config.show_captions) {
this.trigger('videoCaption.pause', null); this.trigger('videoCaption.pause', null);
} }
// When only `startTime` is set, the video will play to the end
// starting at `startTime`. After the first time the video reaches the
// end, `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
// Sometimes `onEnded` events fires when `currentTime` not equal // Sometimes `onEnded` events fires when `currentTime` not equal
// `duration`. In this case, slider doesn't reach the end point of // `duration`. In this case, slider doesn't reach the end point of
// timeline. // timeline.
...@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) { ...@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) {
this.trigger('videoControl.play', null); this.trigger('videoControl.play', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: false
});
if (this.config.show_captions) { if (this.config.show_captions) {
this.trigger('videoCaption.play', null); this.trigger('videoCaption.play', null);
} }
...@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) { ...@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) {
function updatePlayTime(time) { function updatePlayTime(time) {
var duration = this.videoPlayer.duration(), var duration = this.videoPlayer.duration(),
durationChange; durationChange, tempStartTime, tempEndTime;
if ( if (
duration > 0 && duration > 0 &&
...@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) { ...@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) {
this.videoPlayer.initialSeekToStartTime === false this.videoPlayer.initialSeekToStartTime === false
) { ) {
durationChange = true; durationChange = true;
} else { } else { // this.videoPlayer.initialSeekToStartTime === true
this.videoPlayer.initialSeekToStartTime = false;
durationChange = false; durationChange = false;
} }
this.videoPlayer.initialSeekToStartTime = false;
this.videoPlayer.seekToStartTimeOldSpeed = this.speed; this.videoPlayer.seekToStartTimeOldSpeed = this.speed;
// Current startTime and endTime could have already been reset.
// We will remember their current values, and reset them at the
// end. We need to perform the below calculations on start and end
// times so that the range on the slider gets correctly updated in
// the case of speed change in Flash player mode (for YouTube
// videos).
tempStartTime = this.videoPlayer.startTime;
tempEndTime = this.videoPlayer.endTime;
// We retrieve the original times. They could have been changed due // We retrieve the original times. They could have been changed due
// to the fact of speed change (duration change). This happens when // to the fact of speed change (duration change). This happens when
// in YouTube Flash mode. There each speed is a different video, // in YouTube Flash mode. There each speed is a different video,
...@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) { ...@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) {
this.videoPlayer.startTime /= Number(this.speed); this.videoPlayer.startTime /= Number(this.speed);
} }
} }
// An `endTime` of `null` means that either the user didn't set
// and `endTime`, or it was set to a value greater than the
// duration of the video.
//
// If `endTime` is `null`, the video will play to the end. We do
// not set the `endTime` to the duration of the video because
// sometimes in YouTube mode the duration changes slightly during
// the course of playback. This would cause the video to pause just
// before the actual end of the video.
if ( if (
this.videoPlayer.endTime === null || this.videoPlayer.endTime !== null &&
this.videoPlayer.endTime > duration this.videoPlayer.endTime > duration
) { ) {
this.videoPlayer.endTime = duration; this.videoPlayer.endTime = null;
} else { } else if (this.videoPlayer.endTime !== null) {
if (this.currentPlayerMode === 'flash') { if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime /= Number(this.speed); this.videoPlayer.endTime /= Number(this.speed);
} }
} }
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero.
if (durationChange === false && this.videoPlayer.startTime > 0) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Rebuild the slider start-end range (if it doesn't take up the // Rebuild the slider start-end range (if it doesn't take up the
// whole slider). // whole slider). Remember that endTime === null means the end time
// is set to the end of video by default.
if (!( if (!(
this.videoPlayer.startTime === 0 && this.videoPlayer.startTime === 0 &&
this.videoPlayer.endTime === duration this.videoPlayer.endTime === null
)) { )) {
this.trigger( this.trigger(
'videoProgressSlider.updateStartEndTimeRegion', 'videoProgressSlider.updateStartEndTimeRegion',
...@@ -599,6 +630,28 @@ function (HTML5Video, Resizer) { ...@@ -599,6 +630,28 @@ function (HTML5Video, Resizer) {
} }
); );
} }
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero, and we haven't
// performed already such a seek.
if (
durationChange === false &&
this.videoPlayer.startTime > 0 &&
!(tempStartTime === 0 && tempEndTime === null)
) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Reset back the actual startTime and endTime if they have been
// already reset (a seek event happened, the video already ended
// once, or endTime has already been reached once).
if (tempStartTime === 0 && tempEndTime === null) {
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
}
} }
this.trigger( this.trigger(
......
...@@ -41,7 +41,8 @@ function () { ...@@ -41,7 +41,8 @@ function () {
onSlide: onSlide, onSlide: onSlide,
onStop: onStop, onStop: onStop,
updatePlayTime: updatePlayTime, updatePlayTime: updatePlayTime,
updateStartEndTimeRegion: updateStartEndTimeRegion updateStartEndTimeRegion: updateStartEndTimeRegion,
notifyThroughHandleEnd: notifyThroughHandleEnd
}; };
state.bindTo(methodsDict, state.videoProgressSlider, state); state.bindTo(methodsDict, state.videoProgressSlider, state);
...@@ -111,11 +112,6 @@ function () { ...@@ -111,11 +112,6 @@ function () {
duration = params.duration; duration = params.duration;
} }
// If the range spans the entire length of video, we don't do anything.
if (!this.videoPlayer.startTime && !this.videoPlayer.endTime) {
return;
}
start = this.videoPlayer.startTime; start = this.videoPlayer.startTime;
// If end is set to null, then we set it to the end of the video. We // If end is set to null, then we set it to the end of the video. We
...@@ -199,8 +195,6 @@ function () { ...@@ -199,8 +195,6 @@ function () {
}, 200); }, 200);
} }
// Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera
// noticed
function updatePlayTime(params) { function updatePlayTime(params) {
var time = Math.floor(params.time), var time = Math.floor(params.time),
duration = Math.floor(params.duration); duration = Math.floor(params.duration);
...@@ -215,6 +209,33 @@ function () { ...@@ -215,6 +209,33 @@ function () {
} }
} }
// When the video stops playing (either because the end was reached, or
// because endTime was reached), the screen reader must be notified that
// the video is no longer playing. We do this by a little trick. Setting
// the title attribute of the slider know to "video ended", and focusing
// on it. The screen reader will read the attr text.
//
// The user can then tab his way forward, landing on the next control
// element, the Play button.
//
// @param params - object with property `end`. If set to true, the
// function must set the title attribute to
// `video ended`;
// if set to false, the function must reset the attr to
// it's original state.
//
// This function will be triggered from VideoPlayer methods onEnded(),
// onPlay(), and update() (update method handles endTime).
function notifyThroughHandleEnd(params) {
if (params.end) {
this.videoProgressSlider.handle
.attr('title', 'video ended')
.focus();
} else {
this.videoProgressSlider.handle.attr('title', 'video position');
}
}
// Returns a string describing the current time of video in hh:mm:ss // Returns a string describing the current time of video in hh:mm:ss
// format. // format.
function getTimeDescription(time) { function getTimeDescription(time) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment