diff --git a/library.json b/library.json index b2aac18f..d3a99e2c 100644 --- a/library.json +++ b/library.json @@ -14,6 +14,9 @@ "minorVersion": 4 }, "preloadedJs": [ + { + "path": "scripts/x-api.js" + }, { "path": "scripts/youtube.js" }, diff --git a/scripts/html5.js b/scripts/html5.js index e24ff3d4..85a28618 100644 --- a/scripts/html5.js +++ b/scripts/html5.js @@ -1,5 +1,6 @@ /** @namespace H5P */ H5P.VideoHtml5 = (function ($) { + 'use strict'; /** * HTML5 video player for H5P. @@ -35,6 +36,12 @@ H5P.VideoHtml5 = (function ($) { var stateBeforeChangingQuality; var currentTimeBeforeChangingQuality; + /** + * Track xAPI statement data for video events. + * @private + */ + var lastSend = null; + /** * Avoids firing the same event twice. * @private @@ -143,6 +150,27 @@ H5P.VideoHtml5 = (function ($) { } }); + /** + * Create the xAPI object for the 'Initialized' event. + */ + var getLoadedParams = function () { + var ccEnabled = false; + var ccLanguage; + + for (var i = 0; i < video.textTracks.length; i++) { + if (video.textTracks[i].mode === 'showing') { + ccEnabled = true; + ccLanguage = video.textTracks[i].language; + } + } + + return self.videoXAPI.getArgsXAPIInitialized(video.videoWidth, video.videoHeight, video.playbackRate, video.volume, ccEnabled, ccLanguage, video.videoHeight, video.duration); + + }; + + // Set duration used for xAPI statements. + self.duration = video.duration; + /** * Helps registering events. * @@ -153,6 +181,8 @@ H5P.VideoHtml5 = (function ($) { */ var mapEvent = function (native, h5p, arg) { video.addEventListener(native, function () { + var extraArg = null; + var extraTrigger = null; switch (h5p) { case 'stateChange': if (lastState === arg) { @@ -165,8 +195,63 @@ H5P.VideoHtml5 = (function ($) { delete options.startAt; } - break; + if (arg === H5P.Video.PLAYING) { + if (lastSend !== 'play') { + extraArg = self.videoXAPI.getArgsXAPIPlayed(video.currentTime); + extraTrigger = 'play'; + lastSend = 'play'; + } + } + if (arg === H5P.Video.PAUSED) { + // Put together extraArg for sending to xAPI statement. + if (!video.seeking && self.seeking === false && video.currentTime !== video.duration && self.previousState !== H5P.Video.BUFFERING) { + extraTrigger = 'paused'; + extraArg = self.videoXAPI.getArgsXAPIPaused(video.currentTime, video.duration); + lastSend = 'paused'; + } + } + + if (arg === H5P.Video.ENDED) { + // Send extra trigger for giving progress on ended call to xAPI. + var length = video.duration; + if (length > 0) { + // Length passed in as current time, because at end of video when this is fired currentTime reset to 0 if on loop + var progress = self.videoXAPI.getProgress(length, length); + if (progress >= self.finishedThreshold) { + extraTrigger = 'finished'; + extraArg = self.videoXAPI.getArgsXAPICompleted(video.currentTime, video.duration, progress); + lastSend = 'finished'; + } + } + } + break; + case 'seeked': + return; // Seek is tracked differently based on time difference in timeupdate. + break; + case 'seeking': + return; // Just need to store current time for seeked event. + break; + case 'volumechange' : + arg = self.videoXAPI.getArgsXAPIVolumeChanged(video.currentTime, video.muted, video.volume); + lastSend = 'volumechange'; + break; + case 'play': + if (self.seeking === false && lastSend !== h5p) { + arg = self.videoXAPI.getArgsXAPIPlayed(video.currentTime); + lastSend = h5p; + } + else { + arg = self.videoXAPI.getArgsXAPISeeked(self.seekedTo); + lastSend = 'seeked'; + self.seeking = false; + h5p = 'seeked'; + } + break; + case 'fullscreen': + arg = self.videoXAPI.getArgsXAPIFullScreen(video.currentTime, video.videoWidth, video.videoHeight); + lastSend = h5p; + break; case 'loaded': isLoaded = true; @@ -188,8 +273,12 @@ H5P.VideoHtml5 = (function ($) { video.addEventListener('durationchange', andLoaded, false); return; } - break; + extraTrigger = 'xAPIloaded'; + extraArg = getLoadedParams(); + lastSend = 'xAPIloaded'; + + break; case 'error': // Handle error and get message. arg = error(arguments[0], arguments[1]); @@ -213,7 +302,13 @@ H5P.VideoHtml5 = (function ($) { arg = self.getPlaybackRate(); break; } + self.previousState = arg; self.trigger(h5p, arg); + + // Make extra calls for events with needed values for xAPI statement. + if (extraTrigger !== null && extraArg !== null) { + self.trigger(extraTrigger, extraArg); + } }, false); }; @@ -425,8 +520,12 @@ H5P.VideoHtml5 = (function ($) { video.play(); video.pause(); } - + if (self.seeking === false) { + self.previousTime = video.currentTime; + } video.currentTime = time; + self.seeking = true; + self.seekedTo = time; }; /** @@ -596,6 +695,11 @@ H5P.VideoHtml5 = (function ($) { mapEvent('loadedmetadata', 'loaded'); mapEvent('error', 'error'); mapEvent('ratechange', 'playbackRateChange'); + mapEvent('seeking','seeking', H5P.Video.PAUSED); + mapEvent('timeupdate', 'timeupdate', H5P.Video.PLAYING); + mapEvent('volumechange', 'volumechange'); + mapEvent('play', 'play', H5P.Video.PLAYING); + mapEvent('webkitfullscreenchange mozfullscreenchange fullscreenchange MSFullscreenChange', 'fullscreen'); if (!video.controls) { // Disable context menu(right click) to prevent controls. @@ -603,7 +707,6 @@ H5P.VideoHtml5 = (function ($) { event.preventDefault(); }, false); } - // Display throbber when buffering/loading video. self.on('stateChange', function (event) { var state = event.data; diff --git a/scripts/video.js b/scripts/video.js index 796379cd..d820c840 100644 --- a/scripts/video.js +++ b/scripts/video.js @@ -1,5 +1,6 @@ /** @namespace H5P */ H5P.Video = (function ($, ContentCopyrights, MediaCopyright, handlers) { + 'use strict'; /** * The ultimate H5P video player! @@ -15,9 +16,36 @@ H5P.Video = (function ($, ContentCopyrights, MediaCopyright, handlers) { function Video(parameters, id) { var self = this; + self.videoXAPI = new H5P.VideoXAPI(self); + // Ref youtube.js - ipad & youtube - issue self.pressToPlay = false; + self.finishedThreshold = 0.95; + + // Values needed for xAPI triggering, set by handlers + self.previousTime = 0; + self.seeking = false; + self.seekedTo = 0; + self.duration = 0; + self.previousState = -1; + self.mousedown = false; + + /* + * Used to distinguish seeking from pausing + * TODO: This might be much cleaner with refactoring IV, video and the handlers + */ + document.addEventListener('mousedown', function() { + self.mousedown = true; + }); + document.addEventListener('mouseup', function() { + if (self.seeking) { + self.trigger('seeked', self.videoXAPI.getArgsXAPISeeked(self.seekedTo)); + self.seeking = false; + } + self.mousedown = false; + }); + // Reference to the handler var handlerName = ''; @@ -128,6 +156,35 @@ H5P.Video = (function ($, ContentCopyrights, MediaCopyright, handlers) { self.on('loaded', function () { self.trigger('resize'); }); + // xAPI extension events for video. + self.on('seeked', function (event) { + self.triggerXAPI('seeked', event.data); + }); + self.on('volumechange', function (event) { + self.triggerXAPI('interacted', event.data); + }); + self.on('finished', function (event) { + // Triggered as finished to be seperate from H5Ps completed, + // but statement is sent as completed and differentiated by object.id + self.triggerXAPI('completed', event.data); + }); + self.on('fullscreen', function (event) { + // Note: youtube.js and html5.js players do not fire this event. + self.triggerXAPI('interacted', event.data); + }); + self.on('play', function (event) { + self.triggerXAPI('played', event.data); + }); + self.on('xAPIloaded', function (event) { + self.duration = self.getDuration(); + self.triggerXAPI('initialized', event.data); + }); + self.on('paused', function (event) { + // if mouse button is down, we're seeking + if (self.mousedown === false) { + self.triggerXAPI('paused', event.data); + } + }); // Find player for video sources if (sources.length) { diff --git a/scripts/x-api.js b/scripts/x-api.js new file mode 100644 index 00000000..7a012a79 --- /dev/null +++ b/scripts/x-api.js @@ -0,0 +1,494 @@ +/** @namespace H5P */ +H5P.VideoXAPI = (function ($) { + 'use strict'; + + /** + * Xapi video statement generator for H5P. + * + * @class + * @param {Object} instance Parent H5P.Video{YouTube|Html5|Flash} + * video object generating xAPI statements + */ + function XAPI(instance) { + var self = this; + + /** + * Variables to track time values from the video player. + * + * @public + */ + + /** + * Variables to track internal video state. + * + * @private + */ + var videoInstance = instance; + var playedSegments = []; + var playingSegmentStart = 0; + var volumeChangedOn = null; + var volumeChangedAt = 0; + var sessionID = H5P.createUUID(); + var currentTime = 0; + var xAPIObject = null; + + /** + * Generate common xAPI statement elements (Video Profile). + + * @param {Object} params - Parameters. + * @param {string} params.verb - Verb for the xAPI statement. + * @param {Object} [params.result] - Extensions for the object. + * @param {Object} [params.extensionsContext] - Extensions for the context. + * @return {Object} JSON xAPI statement + */ + self.getArgsXAPI = function (params) { + params.extensionsContext = params.extensionsContext || {}; + + var dateTime = new Date(); + var timeStamp = dateTime.toISOString(); + + return { + 'verb': { + 'id': params.verb, + 'display': {'en-US': params.verb.substr(params.verb.lastIndexOf('/') + 1)} + }, + 'object': getXAPIObject(), + 'result': params.result, + 'context': { + 'contextActivities': {'category': [{'id': 'https://w3id.org/xapi/video'}]}, + 'extensions': params.extensionsContext + }, + 'timestamp': timeStamp + }; + }; + + /** + * Generates 'initialized' xAPI statement (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#231-initialized + * + * @public + * @param {Number} width width of the current screen + * @param {Number} height height of the current video screen + * @param {Number} rate playback rate + * @param {number} volume level of volume + * @param {Boolean} ccEnabled boolean whether closed captions are enabled + * @param {String} ccLanguage language of closed captions + * @param {String} [quality] quality rating of resolution + * @returns {Object} JSON xAPI statement + * + */ + self.getArgsXAPIInitialized = function (width, height, rate, volume, ccEnabled, ccLanguage, quality, videoLength) { + // If quality isn't provided, set it to the height of the video. + quality = typeof quality !== 'undefined' ? quality : height; + videoLength = typeof videoLength !== 'undefined' ? parseFloat(videoLength).toFixed(3) : videoLength; + + // Variables used in compiling xAPI results. + var screenSize = screen.width + 'x' + screen.height; + var playbackSize = (width !== undefined && width !== '') ? width + 'x' + height : undefined; + var playbackRate = rate; + var userAgent = navigator.userAgent; + var isFullscreen = document.fullscreenElement || document.mozFullScreen || document.webkitIsFullScreen || false; + volume = formatFloat(volume); + + var extensions = {}; + + if (typeof videoLength !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/length'] = videoLength; + } + if (typeof isFullscreen !== 'undefined' && isFullscreen) { + extensions['https://w3id.org/xapi/video/extensions/full-screen'] = isFullscreen; + } + if (typeof screenSize !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/screen-size'] = screenSize; + } + if (typeof playbackSize !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/video-playback-size'] = playbackSize; + } + if (typeof sessionID !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/session-id'] = sessionID; + } + if (typeof quality !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/quality'] = quality; + } + if (typeof ccEnabled !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/cc-enabled'] = ccEnabled; + } + if (typeof playbackRate !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/speed'] = playbackRate; + } + if (typeof userAgent !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/user-agent'] = userAgent; + } + if (typeof volume !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/volume'] = volume; + } + if (typeof instance.finishedThreshold !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/completion-threshold'] = instance.finishedThreshold; + } + + return self.getArgsXAPI({ + verb: 'http://adlnet.gov/expapi/verbs/initialized', + extensionsContext: extensions + }); + }; + + /** + * Generates 'played' xAPI statement. + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#232-played + * + * @public + * @param {Number} currentTime time of the video currently + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPIPlayed = function (currentTime) { + var resultExtTime = formatFloat(currentTime); + playingSegmentStart = resultExtTime; + + return self.getArgsXAPI({ + verb: 'https://w3id.org/xapi/video/verbs/played', + result: {extensions: { + 'https://w3id.org/xapi/video/extensions/time': resultExtTime} + }, + extensionsContext: { + 'https://w3id.org/xapi/video/extensions/session-id': sessionID + } + }); + }; + + /** + * Generates 'paused' xAPI statement (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#233-paused + * + * @public + * @param {Number} currentTime time of the video currently + * @param {Number} duration length of the video in seconds + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPIPaused = function (currentTime, duration) { + var resultExtTime = formatFloat(currentTime); + var progress = self.getProgress(currentTime, duration); + endPlayingSegment(resultExtTime); + + var extensions = {}; + if (typeof resultExtTime !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/time'] = resultExtTime; + } + if (typeof progress !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/progress'] = progress; + } + if (typeof playedSegments !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/played-segments'] = stringifyPlayedSegments(); + } + + return self.getArgsXAPI({ + verb: 'https://w3id.org/xapi/video/verbs/paused', + result: {extensions: extensions}, + extensionsContext: { + 'https://w3id.org/xapi/video/extensions/session-id': sessionID + } + }); + }; + + /** + * Generates 'seeked' xAPI statement (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#234-seeked + * + * @public + * @param {Number} currentTime time of the video currently + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPISeeked = function (currentTime) { + var resultExtTime = formatFloat(currentTime); + endPlayingSegment(formatFloat(instance.previousTime)); + playingSegmentStart = resultExtTime; + + return self.getArgsXAPI({ + verb: 'https://w3id.org/xapi/video/verbs/seeked', + result: { + extensions: { + 'https://w3id.org/xapi/video/extensions/time-from': formatFloat(instance.previousTime), + 'https://w3id.org/xapi/video/extensions/time-to': playingSegmentStart + } + }, + extensionsContext: { + 'https://w3id.org/xapi/video/extensions/session-id': sessionID + } + }); + }; + + /** + * Generates 'interacted' xAPI statement when volume changes (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#235-interacted + * + * @public + * @param {Number} currentTime time of the video currently + * @param {Boolean} muted indicates whether video is currently muted + * @param {Number} volume indicates the volume level + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPIVolumeChanged = function (currentTime, muted, volume) { + volumeChangedAt = formatFloat(currentTime); + volume = muted ? 0 : formatFloat(volume); + + return self.getArgsXAPI({ + verb: 'http://adlnet.gov/expapi/verbs/interacted', + result: {extensions: { + 'https://w3id.org/xapi/video/extensions/time': volumeChangedAt} + }, + extensionsContext: { + 'https://w3id.org/xapi/video/extensions/session-id': sessionID, + 'https://w3id.org/xapi/video/extensions/volume': volume + } + }); + }; + + /** + * Generates 'interacted' xAPI statement when fullscreen entered (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#235-interacted + * + * @public + * @param {Number} currentTime time of the video currently + * @param {Number} width width of the current video screen (pixels) + * @param {Number} height height of the current video screen (pixels) + * @param {Boolean} [fullscreen] indicates whether user is watching in full screen mode or not + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPIFullScreen = function (currentTime, width, height, fullscreen) { + fullscreen = typeof fullscreen !== 'undefined' ? fullscreen : false; + var isFullscreen = document.fullscreenElement || document.mozFullScreen || document.webkitIsFullScreen || fullscreen; + var screenSize = screen.width + 'x' + screen.height; + var playbackSize = width + 'x' + height; + + var resultExtTime = formatFloat(currentTime); + + var extensions = {}; + if (typeof sessionID !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/session-id'] = sessionID; + } + if (typeof isFullscreen !== 'undefined' && isFullscreen) { + extensions['https://w3id.org/xapi/video/extensions/full-screen'] = isFullscreen; + } + if (typeof screenSize !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/screen-size'] = screenSize; + } + if (typeof playbackSize !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/video-playback-size'] = playbackSize; + } + + return self.getArgsXAPI({ + verb: 'http://adlnet.gov/expapi/verbs/interacted', + result: {extensions: { + 'https://w3id.org/xapi/video/extensions/time': resultExtTime} + }, + extensionsContext: extensions + }); + }; + + /** + * Generates 'completed' xAPI statement (Video Profile). + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#236-completed + * + * @public + * @param {Number} currentTime time of the video currently + * @param {Number} duration length of the current video in seconds + * @param {Number} progress Number between 0.000 and 1.000 indicating percentage of video watched + * @returns {Object} JSON xAPI statement + */ + self.getArgsXAPICompleted = function (currentTime, duration, progress) { + var resultExtTime = formatFloat(currentTime); + endPlayingSegment(resultExtTime); + playingSegmentStart = 0; + + var extensions = {}; + if (typeof resultExtTime !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/time'] = resultExtTime; + } + if (typeof progress !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/progress'] = progress; + } + if (typeof playedSegments !== 'undefined') { + extensions['https://w3id.org/xapi/video/extensions/played-segments'] = stringifyPlayedSegments(); + } + + return self.getArgsXAPI({ + verb: 'http://adlnet.gov/expapi/verbs/completed', + result: { + 'extensions': extensions, + 'completion': true, + 'duration': secondsToISO8601Duration(duration) + }, + extensionsContext: { + 'https://w3id.org/xapi/video/extensions/session-id': sessionID + } + }); + }; + + /** + * Calculate video progress. + * + * @public + * @param {Number} currentTime current time of the video in seconds + * @param {Number} duration length of the video in seconds + * @returns {Number} Progress between 0..1 + */ + self.getProgress = function (currentTime, duration) { + // If we're currently playing a segment, end it so it's included in our + // calculations below. + endPlayingSegment(currentTime); + + // Create a copy of the played segments array so we can manipulate it. + var parsedPlayedSegments = JSON.parse(JSON.stringify(playedSegments)); + + // Sort the array (so we can detect overlapping segments). + parsedPlayedSegments.sort(function (a, b) { + return a.start - b.start; + }); + + // Calculate total time watched from played segments. + var timePlayed = 0; + parsedPlayedSegments.forEach(function (currentValue, i, segments) { + // If a segment overlaps, discard the overlap (otherwise our progress + // count would be artificially inflated). + if (i > 0 && segments[i].start < segments[i-1].end) { + segments[i].start = segments[i-1].end; + // This segment may have been inside the previous segment, so be sure + // to update its end timestamp so we don't have a negative range). + segments[i].end = Math.max(segments[i].start, segments[i].end); + } + // Add this segment's length to our cumulative progress counter. + timePlayed += segments[i].end - segments[i].start; + }); + + // Progress (percentage) is encoded as a decimal between 0.000 and 1.000. + // @see: https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#2544-progress + return formatFloat(timePlayed / duration); + }; + + /** + * Adds a played segment to the array of already played segments. + * + * @private + * @param {Number} endTime When the currently playing segment ended + */ + var endPlayingSegment = function (endTime) { + // Scrubbing the video will fire this function many times, so only record + // segments 500ms or longer (ignore any segments less than 500ms and + // negative play segments). + if (endTime - playingSegmentStart > 0.5) { + playedSegments.push({ + start: formatFloat(playingSegmentStart), + end: formatFloat(endTime) + }); + playingSegmentStart = endTime; + } + }; + + /** + * Converts an array of played segments to the string representation defined + * in the xAPI Video Profile spec. + * @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#2545-played-segments + * + * @private + * @return {String} Played segments string, e.g., '0.000[.]12.000[,]14.000[.]21.000' + */ + var stringifyPlayedSegments = function () { + var stringPlayedSegments = ''; + if (playedSegments.length > 0) { + stringPlayedSegments = playedSegments.map(function (segment) { + return segment.start.toFixed(3) + '[.]' + segment.end.toFixed(3); + }).reduce(function (accumulator, segment) { + return accumulator + '[,]' + segment; + }); + } + + return stringPlayedSegments; + }; + + /** + * Append extra data to the XAPI statement's default object definition. + * + * @private + * @returns {Object} 'Object' portion of JSON xAPI statement + */ + var getXAPIObject = function () { + if (xAPIObject !== null) { + return xAPIObject; + } + + var event = new H5P.XAPIEvent(); + + if (videoInstance && videoInstance.contentId && H5PIntegration && H5PIntegration.contents && H5PIntegration.contents['cid-' + videoInstance.contentId]) { + event.setObject(videoInstance); + xAPIObject = event.data.statement.object; + + // Add definition type (required by xAPI Video Profile). + // @see https://liveaspankaj.gitbooks.io/xapi-video-profile/content/statement_data_model.html#241-definition + xAPIObject.definition.type = 'https://w3id.org/xapi/video/activity-type/video'; + + // Add definition description (if video has a description). + if (H5PIntegration.contents['cid-' + videoInstance.contentId].jsonContent) { + var videoData = JSON.parse(H5PIntegration.contents['cid-' + videoInstance.contentId].jsonContent); + if (videoData && videoData.interactiveVideo && videoData.interactiveVideo.video && videoData.interactiveVideo.video.startScreenOptions && videoData.interactiveVideo.video.startScreenOptions.shortStartDescription) { + xAPIObject.definition.description = { + 'en-US': videoData.interactiveVideo.video.startScreenOptions.shortStartDescription + }; + } + } + } + return xAPIObject; + }; + } + + /** + * Returns a floating point value with up to 3 decimals of precision (or null + * if invalid). Used when making arguments sent with video xAPI statments. + * + * @private + * @param {string} Number to convert to float + * @returns {Number} Floating point with up to 3 decimals of precision + */ + var formatFloat = function (number) { + if (number === null) { + return null; + } + + return +(parseFloat(number).toFixed(3)); + }; + + /** + * Convert duration in seconds to an ISO8601 duration string. + * + * @private + * @param {Number} time Duration in seconds + * @returns {String} Duration in ISO8601 duration format + */ + var secondsToISO8601Duration = function (time) { + var units = { + 'Y': (365*24*3600), + 'D': (24*3600), + 'H': 3600, + 'M': 60, + 'S': 1, + }; + var timeUnits = ['H', 'M', 'S']; + var iso8601Duration = 'P'; + var isTime = false; + for (var unitName in units) { + var unit = units[unitName]; + var quot = Math.floor(time / unit); + time = time - (quot * unit); + unit = quot; + if (unit > 0) { + if (!isTime && (timeUnits.indexOf(unitName) > -1)) { + iso8601Duration += 'T'; + isTime = true; + } + iso8601Duration += '' + unit + '' + unitName; + } + } + + return iso8601Duration; + }; + + return XAPI; +})(H5P.jQuery); diff --git a/scripts/youtube.js b/scripts/youtube.js index b452d2e4..2a4fa7c4 100644 --- a/scripts/youtube.js +++ b/scripts/youtube.js @@ -1,5 +1,6 @@ /** @namespace H5P */ H5P.VideoYouTube = (function ($) { + 'use strict'; /** * YouTube video player for H5P. @@ -76,6 +77,7 @@ H5P.VideoYouTube = (function ($) { onReady: function () { self.trigger('ready'); self.trigger('loaded'); + self.trigger('xAPIloaded', getLoadedParams()); }, onApiChange: function () { if (loadCaptionsModule) { @@ -104,7 +106,7 @@ H5P.VideoYouTube = (function ($) { } }, onStateChange: function (state) { - if (state.data > -1 && state.data < 4) { + if (state.data >= H5P.Video.ENDED && state.data <= H5P.Video.BUFFERING) { // Fix for keeping playback rate in IE11 if (H5P.Video.IE11_PLAYBACK_RATE_FIX && state.data === H5P.Video.PLAYING && playbackRate !== 1) { @@ -115,7 +117,34 @@ H5P.VideoYouTube = (function ($) { // End IE11 fix self.trigger('stateChange', state.data); + + // Calls for xAPI events. + if (state.data === H5P.Video.PLAYING) { + // Get and send play call when not seeking. + if (self.seeking === false) { + self.trigger('play', self.videoXAPI.getArgsXAPIPlayed(player.getCurrentTime())); + } + } + else if (state.data === H5P.Video.PAUSED) { + // This is a paused event. + if (self.seeking === false && self.previousState !== H5P.Video.BUFFERING) { + self.trigger('paused', self.videoXAPI.getArgsXAPIPaused(player.getCurrentTime(), self.duration)); + } + } + else if (state.data === H5P.Video.ENDED) { + // Send xapi trigger if video progress indicates finished. + var length = self.duration; + if (length > 0) { + // Length passed in as current time, because at end of video when this is fired currentTime reset to 0 if on loop + var progress = self.videoXAPI.getProgress(length, length); + if (progress >= self.finishedThreshold) { + var arg = self.videoXAPI.getArgsXAPICompleted(player.getCurrentTime(), self.duration, progress); + self.trigger('finished', arg); + } + } + } } + self.previousState = state.data; }, onPlaybackQualityChange: function (quality) { self.trigger('qualityChange', quality.data); @@ -149,6 +178,65 @@ H5P.VideoYouTube = (function ($) { }); }; + /** + * Helper to calculate video dimensions (used in xAPI statements). + * + * @private + * @param {string} Which dimension to return ('width' or 'height') + */ + var getWidthOrHeight = function (returnType) { + var quality = self.getQuality(); + var width; + var height; + + switch (quality) { + case 'small': + width = '320'; + height = '240'; + break; + case 'medium': + width = '640'; + height = '360'; + break; + case 'large': + width = '853'; + height = '480'; + break; + case 'hd720': + width = '640'; + height = '360'; + break; + case 'hd1080': + width = '1920'; + height = '1080'; + break; + case 'highres': + width = '1920'; + height = '1080'; + break; + } + + return (returnType.toLowerCase().trim() === 'width') ? width : height; + }; + + /** + * Create the xAPI object for the 'Loaded' event. + * + * @private + */ + var getLoadedParams = function () { + var height = getWidthOrHeight('height'); + var width = getWidthOrHeight('width'); + var ccEnabled = player.getOptions().indexOf('cc') !== -1; + var ccLanguage; + if (ccEnabled) { + ccLanguage = player.getOptions('cc', 'track').languageCode; + } + + return self.videoXAPI.getArgsXAPIInitialized(width, height, self.getPlaybackRate(), self.getVolume(), ccEnabled, ccLanguage, self.getQuality(), self.getDuration()); + + }; + /** * Indicates if the video must be clicked for it to start playing. * For instance YouTube videos on iPad must be pressed to start playing. @@ -265,7 +353,12 @@ H5P.VideoYouTube = (function ($) { return; } + if (self.seeking === false) { + self.previousTime = player.getCurrentTime(); + } player.seekTo(time, true); + self.seekedTo = time; + self.seeking = true; }; /** @@ -320,6 +413,8 @@ H5P.VideoYouTube = (function ($) { return; } + self.trigger('volumechange', self.videoXAPI.getArgsXAPIVolumeChanged(player.getCurrentTime(), true, player.getVolume())); + player.mute(); }; @@ -333,6 +428,8 @@ H5P.VideoYouTube = (function ($) { return; } + self.trigger('volumechange', self.videoXAPI.getArgsXAPIVolumeChanged(player.getCurrentTime(), false, player.getVolume())); + player.unMute(); }; @@ -375,6 +472,8 @@ H5P.VideoYouTube = (function ($) { return; } + self.trigger('volumechange', self.videoXAPI.getArgsXAPIVolumeChanged(player.getCurrentTime(), player.isMuted(), level)); + player.setVolume(level); };