var H5P = H5P || {}; /** * Constructor. * * @param {object} params Options for this library. */ H5P.Text = function (params) { this.text = params.text === undefined ? 'New text' : params.text; }; /** * Wipe out the content of the wrapper and put our HTML in it. * * @param {jQuery} $wrapper */ H5P.Text.prototype.attach = function ($wrapper) { $wrapper.addClass('h5p-text').html(this.text); }; ; /** @namespace H5P */ H5P.VideoVimeo = (function ($) { let numInstances = 0; /** * Vimeo video player for H5P. * * @class * @param {Array} sources Video files to use * @param {Object} options Settings for the player * @param {Object} l10n Localization strings */ function VimeoPlayer(sources, options, l10n) { const self = this; let player; // Since all the methods of the Vimeo Player SDK are promise-based, we keep // track of all relevant state variables so that we can implement the // H5P.Video API where all methods return synchronously. let buffered = 0; let currentQuality; let currentTextTrack; let currentTime = 0; let duration = 0; let isMuted = 0; let volume = 0; let playbackRate = 1; let qualities = []; let loadingFailedTimeout; let failedLoading = false; let ratio = 9/16; const LOADING_TIMEOUT_IN_SECONDS = 8; const id = `h5p-vimeo-${++numInstances}`; const $wrapper = $('
'); const $placeholder = $('
', { id: id, html: `
` }).appendTo($wrapper); /** * Create a new player with the Vimeo Player SDK. * * @private */ const createVimeoPlayer = async () => { if (!$placeholder.is(':visible') || player !== undefined) { return; } // Since the SDK is loaded asynchronously below, explicitly set player to // null (unlike undefined) which indicates that creation has begun. This // allows the guard statement above to be hit if this function is called // more than once. player = null; const Vimeo = await loadVimeoPlayerSDK(); const MIN_WIDTH = 200; const width = Math.max($wrapper.width(), MIN_WIDTH); const canHasControls = options.controls || self.pressToPlay; const embedOptions = { url: sources[0].path, controls: canHasControls, responsive: true, dnt: true, // Hardcoded autoplay to false to avoid playing videos on init autoplay: false, loop: options.loop ? true : false, playsinline: true, quality: 'auto', width: width, muted: false, keyboard: canHasControls, }; // Create a new player player = new Vimeo.Player(id, embedOptions); registerVimeoPlayerEventListeneners(player); // Failsafe timeout to handle failed loading of videos. // This seems to happen for private videos even though the SDK docs // suggests to catch PrivacyError when attempting play() loadingFailedTimeout = setTimeout(() => { failedLoading = true; removeLoadingIndicator(); $wrapper.html(`

${l10n.vimeoLoadingError}

`); $wrapper.css({ width: null, height: null }); self.trigger('resize'); self.trigger('error', l10n.vimeoLoadingError); }, LOADING_TIMEOUT_IN_SECONDS * 1000); } const removeLoadingIndicator = () => { $placeholder.find('div.h5p-video-loading').remove(); }; /** * Register event listeners on the given Vimeo player. * * @private * @param {Vimeo.Player} player */ const registerVimeoPlayerEventListeneners = (player) => { let isFirstPlay, tracks; player.on('loaded', async () => { isFirstPlay = true; clearTimeout(loadingFailedTimeout); const videoDetails = await getVimeoVideoMetadata(player); tracks = videoDetails.tracks.options; currentTextTrack = tracks.current; duration = videoDetails.duration; qualities = videoDetails.qualities; currentQuality = 'auto'; try { ratio = videoDetails.dimensions.height / videoDetails.dimensions.width; } catch (e) { /* Intentionally ignore this, and fallback on the default ratio */ } removeLoadingIndicator(); if (options.startAt) { // Vimeo.Player doesn't have an option for setting start time upon // instantiation, so we instead perform an initial seek here. currentTime = await self.seek(options.startAt); } self.trigger('ready'); self.trigger('loaded'); self.trigger('qualityChange', currentQuality); self.trigger('resize'); }); player.on('play', () => { if (isFirstPlay) { isFirstPlay = false; if (tracks.length) { self.trigger('captions', tracks); } } }); // Handle playback state changes. player.on('playing', () => self.trigger('stateChange', H5P.Video.PLAYING)); player.on('pause', () => self.trigger('stateChange', H5P.Video.PAUSED)); player.on('ended', () => self.trigger('stateChange', H5P.Video.ENDED)); // Track the percentage of video that has finished loading (buffered). player.on('progress', (data) => { buffered = data.percent * 100; }); // Track the current time. The update frequency may be browser-dependent, // according to the official docs: // https://developer.vimeo.com/player/sdk/reference#timeupdate player.on('timeupdate', (time) => { currentTime = time.seconds; }); }; /** * Get metadata about the video loaded in the given Vimeo player. * * Example resolved value: * * ``` * { * "duration": 39, * "qualities": [ * { * "name": "auto", * "label": "Auto" * }, * { * "name": "1080p", * "label": "1080p" * }, * { * "name": "720p", * "label": "720p" * } * ], * "dimensions": { * "width": 1920, * "height": 1080 * }, * "tracks": { * "current": { * "label": "English", * "value": "en" * }, * "options": [ * { * "label": "English", * "value": "en" * }, * { * "label": "Norsk bokmål", * "value": "nb" * } * ] * } * } * ``` * * @private * @param {Vimeo.Player} player * @returns {Promise} */ const getVimeoVideoMetadata = (player) => { // Create an object for easy lookup of relevant metadata const massageVideoMetadata = (data) => { const duration = data[0]; const qualities = data[1].map(q => ({ name: q.id, label: q.label })); const tracks = data[2].reduce((tracks, current) => { const h5pVideoTrack = new H5P.Video.LabelValue(current.label, current.language); tracks.options.push(h5pVideoTrack); if (current.mode === 'showing') { tracks.current = h5pVideoTrack; } return tracks; }, { current: undefined, options: [] }); const dimensions = { width: data[3], height: data[4] }; return { duration, qualities, tracks, dimensions }; }; return Promise.all([ player.getDuration(), player.getQualities(), player.getTextTracks(), player.getVideoWidth(), player.getVideoHeight(), ]).then(data => massageVideoMetadata(data)); } try { if (document.featurePolicy.allowsFeature('autoplay') === false) { self.pressToPlay = true; } } catch (err) {} /** * Appends the video player to the DOM. * * @public * @param {jQuery} $container */ self.appendTo = ($container) => { $container.addClass('h5p-vimeo').append($wrapper); createVimeoPlayer(); }; /** * Get list of available qualities. * * @public * @returns {Array} */ self.getQualities = () => { return qualities; }; /** * Get the current quality. * * @returns {String} Current quality identifier */ self.getQuality = () => { return currentQuality; }; /** * Set the playback quality. * * @public * @param {String} quality */ self.setQuality = async (quality) => { currentQuality = await player.setQuality(quality); self.trigger('qualityChange', currentQuality); }; /** * Start the video. * * @public */ self.play = async () => { if (!player) { self.on('ready', self.play); return; } try { await player.play(); } catch (error) { switch (error.name) { case 'PasswordError': // The video is password-protected self.trigger('error', l10n.vimeoPasswordError); break; case 'PrivacyError': // The video is private self.trigger('error', l10n.vimeoPrivacyError); break; default: self.trigger('error', l10n.unknownError); break; } } }; /** * Pause the video. * * @public */ self.pause = () => { if (player) { player.pause(); } }; /** * Seek video to given time. * * @public * @param {Number} time */ self.seek = async (time) => { currentTime = await player.setCurrentTime(time); }; /** * @public * @returns {Number} Seconds elapsed since beginning of video */ self.getCurrentTime = () => { return currentTime; }; /** * @public * @returns {Number} Video duration in seconds */ self.getDuration = () => { return duration; }; /** * Get percentage of video that is buffered. * * @public * @returns {Number} Between 0 and 100 */ self.getBuffered = () => { return buffered; }; /** * Mute the video. * * @public */ self.mute = async () => { isMuted = await player.setMuted(true); }; /** * Unmute the video. * * @public */ self.unMute = async () => { isMuted = await player.setMuted(false); }; /** * Whether the video is muted. * * @public * @returns {Boolean} True if the video is muted, false otherwise */ self.isMuted = () => { return isMuted; }; /** * Get the video player's current sound volume. * * @public * @returns {Number} Between 0 and 100. */ self.getVolume = () => { return volume; }; /** * Set the video player's sound volume. * * @public * @param {Number} level */ self.setVolume = async (level) => { volume = await player.setVolume(level); }; /** * Get list of available playback rates. * * @public * @returns {Array} Available playback rates */ self.getPlaybackRates = () => { return [0.5, 1, 1.5, 2]; }; /** * Get the current playback rate. * * @public * @returns {Number} e.g. 0.5, 1, 1.5 or 2 */ self.getPlaybackRate = () => { return playbackRate; }; /** * Set the current playback rate. * * @public * @param {Number} rate Must be one of available rates from getPlaybackRates */ self.setPlaybackRate = async (rate) => { playbackRate = await player.setPlaybackRate(rate); self.trigger('playbackRateChange', rate); }; /** * Set current captions track. * * @public * @param {H5P.Video.LabelValue} track Captions to display */ self.setCaptionsTrack = (track) => { if (!track) { return player.disableTextTrack().then(() => { currentTextTrack = null; }); } player.enableTextTrack(track.value).then(() => { currentTextTrack = track; }); }; /** * Get current captions track. * * @public * @returns {H5P.Video.LabelValue} */ self.getCaptionsTrack = () => { return currentTextTrack; }; self.on('resize', () => { if (failedLoading || !$wrapper.is(':visible')) { return; } if (player === undefined) { // Player isn't created yet. Try again. createVimeoPlayer(); return; } // Use as much space as possible $wrapper.css({ width: '100%', height: 'auto' }); const width = $wrapper[0].clientWidth; const height = options.fit ? $wrapper[0].clientHeight : (width * (ratio)); // Validate height before setting if (height > 0) { // Set size $wrapper.css({ width: width + 'px', height: height + 'px' }); } }); } /** * Check to see if we can play any of the given sources. * * @public * @static * @param {Array} sources * @returns {Boolean} */ VimeoPlayer.canPlay = (sources) => { return getId(sources[0].path); }; /** * Find id of Vimeo video from given URL. * * @private * @param {String} url * @returns {String} Vimeo video ID */ const getId = (url) => { // https://stackoverflow.com/a/11660798 const matches = url.match(/^.*(vimeo\.com\/)((channels\/[A-z]+\/)|(groups\/[A-z]+\/videos\/))?([0-9]+)/); if (matches && matches[5]) { return matches[5]; } }; /** * Load the Vimeo Player SDK asynchronously. * * @private * @returns {Promise} Vimeo Player SDK object */ const loadVimeoPlayerSDK = async () => { if (window.Vimeo) { return await Promise.resolve(window.Vimeo); } return await new Promise((resolve, reject) => { const tag = document.createElement('script'); tag.src = 'https://player.vimeo.com/api/player.js'; tag.onload = () => resolve(window.Vimeo); tag.onerror = reject; document.querySelector('script').before(tag); }); }; return VimeoPlayer; })(H5P.jQuery); // Register video handler H5P.videoHandlers = H5P.videoHandlers || []; H5P.videoHandlers.push(H5P.VideoVimeo); ; /** @namespace H5P */ H5P.VideoYouTube = (function ($) { /** * YouTube video player for H5P. * * @class * @param {Array} sources Video files to use * @param {Object} options Settings for the player * @param {Object} l10n Localization strings */ function YouTube(sources, options, l10n) { var self = this; var player; var playbackRate = 1; var id = 'h5p-youtube-' + numInstances; numInstances++; var $wrapper = $('
'); var $placeholder = $('
', { id: id, text: l10n.loading }).appendTo($wrapper); // Optional placeholder // var $placeholder = $('').appendTo($wrapper); /** * Use the YouTube API to create a new player * * @private */ var create = function () { if (!$placeholder.is(':visible') || player !== undefined) { return; } if (window.YT === undefined) { // Load API first loadAPI(create); return; } if (YT.Player === undefined) { return; } var width = $wrapper.width(); if (width < 200) { width = 200; } var loadCaptionsModule = true; var videoId = getId(sources[0].path); player = new YT.Player(id, { width: width, height: width * (9/16), videoId: videoId, playerVars: { origin: ORIGIN, // Hardcoded autoplay to false to avoid playing videos on init autoplay: 0, controls: options.controls ? 1 : 0, disablekb: options.controls ? 0 : 1, fs: 0, loop: options.loop ? 1 : 0, playlist: options.loop ? videoId : undefined, rel: 0, showinfo: 0, iv_load_policy: 3, wmode: "opaque", start: options.startAt, playsinline: 1 }, events: { onReady: function () { self.trigger('ready'); self.trigger('loaded'); }, onApiChange: function () { if (loadCaptionsModule) { loadCaptionsModule = false; // Always load captions player.loadModule('captions'); } var trackList; try { // Grab tracklist from player trackList = player.getOption('captions', 'tracklist'); } catch (err) {} if (trackList && trackList.length) { // Format track list into valid track options var trackOptions = []; for (var i = 0; i < trackList.length; i++) { trackOptions.push(new H5P.Video.LabelValue(trackList[i].displayName, trackList[i].languageCode)); } // Captions are ready for loading self.trigger('captions', trackOptions); } }, onStateChange: function (state) { if (state.data > -1 && state.data < 4) { // Fix for keeping playback rate in IE11 if (H5P.Video.IE11_PLAYBACK_RATE_FIX && state.data === H5P.Video.PLAYING && playbackRate !== 1) { // YT doesn't know that IE11 changed the rate so it must be reset before it's set to the correct value player.setPlaybackRate(1); player.setPlaybackRate(playbackRate); } // End IE11 fix self.trigger('stateChange', state.data); } }, onPlaybackQualityChange: function (quality) { self.trigger('qualityChange', quality.data); }, onPlaybackRateChange: function (playbackRate) { self.trigger('playbackRateChange', playbackRate.data); }, onError: function (error) { var message; switch (error.data) { case 2: message = l10n.invalidYtId; break; case 100: message = l10n.unknownYtId; break; case 101: case 150: message = l10n.restrictedYt; break; default: message = l10n.unknownError + ' ' + error.data; break; } self.trigger('error', message); } } }); }; /** * Indicates if the video must be clicked for it to start playing. * For instance YouTube videos on iPad must be pressed to start playing. * * @public */ if (navigator.userAgent.match(/iPad/i)) { self.pressToPlay = true; } else { try { if (document.featurePolicy.allowsFeature('autoplay') === false) { self.pressToPlay = true; } } catch (err) {} } /** * Appends the video player to the DOM. * * @public * @param {jQuery} $container */ self.appendTo = function ($container) { $container.addClass('h5p-youtube').append($wrapper); create(); }; /** * Get list of available qualities. Not available until after play. * * @public * @returns {Array} */ self.getQualities = function () { if (!player || !player.getAvailableQualityLevels) { return; } var qualities = player.getAvailableQualityLevels(); if (!qualities.length) { return; // No qualities } // Add labels for (var i = 0; i < qualities.length; i++) { var quality = qualities[i]; var label = (LABELS[quality] !== undefined ? LABELS[quality] : 'Unknown'); // TODO: l10n qualities[i] = { name: quality, label: LABELS[quality] }; } return qualities; }; /** * Get current playback quality. Not available until after play. * * @public * @returns {String} */ self.getQuality = function () { if (!player || !player.getPlaybackQuality) { return; } var quality = player.getPlaybackQuality(); return quality === 'unknown' ? undefined : quality; }; /** * Set current playback quality. Not available until after play. * Listen to event "qualityChange" to check if successful. * * @public * @params {String} [quality] */ self.setQuality = function (quality) { if (!player || !player.setPlaybackQuality) { return; } player.setPlaybackQuality(quality); }; /** * Start the video. * * @public */ self.play = function () { if (!player || !player.playVideo) { self.on('ready', self.play); return; } player.playVideo(); }; /** * Pause the video. * * @public */ self.pause = function () { self.off('ready', self.play); if (!player || !player.pauseVideo) { return; } player.pauseVideo(); }; /** * Seek video to given time. * * @public * @param {Number} time */ self.seek = function (time) { if (!player || !player.seekTo) { return; } player.seekTo(time, true); }; /** * Get elapsed time since video beginning. * * @public * @returns {Number} */ self.getCurrentTime = function () { if (!player || !player.getCurrentTime) { return; } return player.getCurrentTime(); }; /** * Get total video duration time. * * @public * @returns {Number} */ self.getDuration = function () { if (!player || !player.getDuration) { return; } return player.getDuration(); }; /** * Get percentage of video that is buffered. * * @public * @returns {Number} Between 0 and 100 */ self.getBuffered = function () { if (!player || !player.getVideoLoadedFraction) { return; } return player.getVideoLoadedFraction() * 100; }; /** * Turn off video sound. * * @public */ self.mute = function () { if (!player || !player.mute) { return; } player.mute(); }; /** * Turn on video sound. * * @public */ self.unMute = function () { if (!player || !player.unMute) { return; } player.unMute(); }; /** * Check if video sound is turned on or off. * * @public * @returns {Boolean} */ self.isMuted = function () { if (!player || !player.isMuted) { return; } return player.isMuted(); }; /** * Return the video sound level. * * @public * @returns {Number} Between 0 and 100. */ self.getVolume = function () { if (!player || !player.getVolume) { return; } return player.getVolume(); }; /** * Set video sound level. * * @public * @param {Number} level Between 0 and 100. */ self.setVolume = function (level) { if (!player || !player.setVolume) { return; } player.setVolume(level); }; /** * Get list of available playback rates. * * @public * @returns {Array} available playback rates */ self.getPlaybackRates = function () { if (!player || !player.getAvailablePlaybackRates) { return; } var playbackRates = player.getAvailablePlaybackRates(); if (!playbackRates.length) { return; // No rates, but the array should contain at least 1 } return playbackRates; }; /** * Get current playback rate. * * @public * @returns {Number} such as 0.25, 0.5, 1, 1.25, 1.5 and 2 */ self.getPlaybackRate = function () { if (!player || !player.getPlaybackRate) { return; } return player.getPlaybackRate(); }; /** * Set current playback rate. * Listen to event "playbackRateChange" to check if successful. * * @public * @params {Number} suggested rate that may be rounded to supported values */ self.setPlaybackRate = function (newPlaybackRate) { if (!player || !player.setPlaybackRate) { return; } playbackRate = Number(newPlaybackRate); player.setPlaybackRate(playbackRate); }; /** * Set current captions track. * * @param {H5P.Video.LabelValue} Captions track to show during playback */ self.setCaptionsTrack = function (track) { player.setOption('captions', 'track', track ? {languageCode: track.value} : {}); }; /** * Figure out which captions track is currently used. * * @return {H5P.Video.LabelValue} Captions track */ self.getCaptionsTrack = function () { var track = player.getOption('captions', 'track'); return (track.languageCode ? new H5P.Video.LabelValue(track.displayName, track.languageCode) : null); }; // Respond to resize events by setting the YT player size. self.on('resize', function () { if (!$wrapper.is(':visible')) { return; } if (!player) { // Player isn't created yet. Try again. create(); return; } // Use as much space as possible $wrapper.css({ width: '100%', height: '100%' }); var width = $wrapper[0].clientWidth; var height = options.fit ? $wrapper[0].clientHeight : (width * (9/16)); // Validate height before setting if (height > 0) { // Set size $wrapper.css({ width: width + 'px', height: height + 'px' }); player.setSize(width, height); } }); } /** * Check to see if we can play any of the given sources. * * @public * @static * @param {Array} sources * @returns {Boolean} */ YouTube.canPlay = function (sources) { return getId(sources[0].path); }; /** * Find id of YouTube video from given URL. * * @private * @param {String} url * @returns {String} YouTube video identifier */ var getId = function (url) { // Has some false positives, but should cover all regular URLs that people can find var matches = url.match(/(?:(?:youtube.com\/(?:attribution_link\?(?:\S+))?(?:v\/|embed\/|watch\/|(?:user\/(?:\S+)\/)?watch(?:\S+)v\=))|(?:youtu.be\/|y2u.be\/))([A-Za-z0-9_-]{11})/i); if (matches && matches[1]) { return matches[1]; } }; /** * Load the IFrame Player API asynchronously. */ var loadAPI = function (loaded) { if (window.onYouTubeIframeAPIReady !== undefined) { // Someone else is loading, hook in var original = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = function (id) { loaded(id); original(id); }; } else { // Load the API our self var tag = document.createElement('script'); tag.src = "https://www.youtube.com/iframe_api"; var firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); window.onYouTubeIframeAPIReady = loaded; } }; /** @constant {Object} */ var LABELS = { highres: '2160p', // Old API support hd2160: '2160p', // (New API) hd1440: '1440p', hd1080: '1080p', hd720: '720p', large: '480p', medium: '360p', small: '240p', tiny: '144p', auto: 'Auto' }; /** @private */ var numInstances = 0; // Extract the current origin (used for security) var ORIGIN = window.location.href.match(/http[s]?:\/\/[^\/]+/); ORIGIN = !ORIGIN || ORIGIN[0] === undefined ? undefined : ORIGIN[0]; // ORIGIN = undefined is needed to support fetching file from device local storage return YouTube; })(H5P.jQuery); // Register video handler H5P.videoHandlers = H5P.videoHandlers || []; H5P.videoHandlers.push(H5P.VideoYouTube); ; /** @namespace H5P */ H5P.VideoPanopto = (function ($) { /** * Panopto video player for H5P. * * @class * @param {Array} sources Video files to use * @param {Object} options Settings for the player * @param {Object} l10n Localization strings */ function Panopto(sources, options, l10n) { var self = this; self.volume = 100; var player; var playbackRate = 1; let canHasAutoplay; var id = 'h5p-panopto-' + numInstances; numInstances++; var $wrapper = $('
'); var $placeholder = $('
', { id: id, html: '
' + l10n.loading + '
' }).appendTo($wrapper); // Determine autoplay/play. try { if (document.featurePolicy.allowsFeature('autoplay') !== false) { canHasAutoplay = true; } } catch (err) {} let canHasPlay = !canHasAutoplay; /** * Use the Panopto API to create a new player * * @private */ var create = function () { if (!$placeholder.is(':visible') || player !== undefined) { return; } if (window.EmbedApi === undefined) { // Load API first loadAPI(create); return; } var width = $wrapper.width(); if (width < 200) { width = 200; } const videoId = getId(sources[0].path); player = new EmbedApi(id, { width: width, height: width * (9/16), serverName: videoId[0], sessionId: videoId[1], videoParams: { // Optional interactivity: 'none', showtitle: false, autohide: true, offerviewer: false, autoplay: false, showbrand: false, start: 0, hideoverlay: !options.controls, }, events: { onIframeReady: function () { $placeholder.children(0).text(''); if (canHasAutoplay) { player.loadVideo(); } self.trigger('containerLoaded'); self.trigger('resize'); // Avoid black iframe if loading is slow }, onReady: function () { self.trigger('loaded'); if (player.hasCaptions()) { const captions = []; const captionTracks = player.getCaptionTracks(); for (trackIndex in captionTracks) { captions.push(new H5P.Video.LabelValue(captionTracks[trackIndex], trackIndex)); } // Select active track currentTrack = player.getSelectedCaptionTrack(); currentTrack = captions[currentTrack] ? captions[currentTrack] : null; self.trigger('captions', captions); } if (!canHasPlay) { self.pause(); // Only autoplay if play() has been called before load } }, onStateChange: function (state) { // TODO: Playback rate fix for IE11? if (state > -1 && state < 4) { self.trigger('stateChange', state); } }, onPlaybackRateChange: function () { self.trigger('playbackRateChange', self.getPlaybackRate()); }, onError: function (error) { if (error === ApiError.PlayWithSoundNotAllowed) { setTimeout(function () { self.unMute(); }, 10); } else { self.trigger('error', l10n.unknownError); } }, onLoginShown: function () { $placeholder.children().first().remove(); // Remove loading message self.trigger('loaded'); // Resize parent } } }); }; /** * Indicates if the video must be clicked for it to start playing. * This is always true for Panopto since all videos auto play. * * @public */ self.pressToPlay = true; /** * Appends the video player to the DOM. * * @public * @param {jQuery} $container */ self.appendTo = function ($container) { $container.addClass('h5p-panopto').append($wrapper); create(); }; /** * Get list of available qualities. Not available until after play. * * @public * @returns {Array} */ self.getQualities = function () { // Not available for Panopto }; /** * Get current playback quality. Not available until after play. * * @public * @returns {String} */ self.getQuality = function () { // Not available for Panopto }; /** * Set current playback quality. Not available until after play. * Listen to event "qualityChange" to check if successful. * * @public * @params {String} [quality] */ self.setQuality = function (quality) { // Not available for Panopto }; /** * Start the video. * * @public */ self.play = function () { canHasPlay = true; if (!player || !player.playVideo) { return; } player.playVideo(); }; /** * Pause the video. * * @public */ self.pause = function () { canHasPlay = false; if (!player || !player.pauseVideo) { return; } try { player.pauseVideo(); } catch (err) { // Swallow Panopto throwing an error. This has been seen in the authoring // tool if Panopto has been used inside Iv inside CP } }; /** * Seek video to given time. * * @public * @param {Number} time */ self.seek = function (time) { if (!player || !player.seekTo) { return; } player.seekTo(time); }; /** * Get elapsed time since video beginning. * * @public * @returns {Number} */ self.getCurrentTime = function () { if (!player || !player.getCurrentTime) { return; } return player.getCurrentTime(); }; /** * Get total video duration time. * * @public * @returns {Number} */ self.getDuration = function () { if (!player || !player.getDuration) { return; } return player.getDuration(); }; /** * Get percentage of video that is buffered. * * @public * @returns {Number} Between 0 and 100 */ self.getBuffered = function () { // Not available for Panopto }; /** * Turn off video sound. * * @public */ self.mute = function () { if (!player || !player.muteVideo) { return; } player.muteVideo(); }; /** * Turn on video sound. * * @public */ self.unMute = function () { if (!player || !player.unmuteVideo) { return; } player.unmuteVideo(); // The volume is set to 0 when the browser prevents autoplay, // causing there to be no sound despite unmuting self.setVolume(self.volume); }; /** * Check if video sound is turned on or off. * * @public * @returns {Boolean} */ self.isMuted = function () { if (!player || !player.isMuted) { return; } return player.isMuted(); }; /** * Return the video sound level. * * @public * @returns {Number} Between 0 and 100. */ self.getVolume = function () { if (!player || !player.getVolume) { return; } return player.getVolume() * 100; }; /** * Set video sound level. * * @public * @param {Number} level Between 0 and 100. */ self.setVolume = function (level) { if (!player || !player.setVolume) { return; } player.setVolume(level/100); self.volume = level; }; /** * Get list of available playback rates. * * @public * @returns {Array} available playback rates */ self.getPlaybackRates = function () { return [0.25, 0.5, 1, 1.25, 1.5, 2]; }; /** * Get current playback rate. * * @public * @returns {Number} such as 0.25, 0.5, 1, 1.25, 1.5 and 2 */ self.getPlaybackRate = function () { if (!player || !player.getPlaybackRate) { return; } return player.getPlaybackRate(); }; /** * Set current playback rate. * Listen to event "playbackRateChange" to check if successful. * * @public * @params {Number} suggested rate that may be rounded to supported values */ self.setPlaybackRate = function (newPlaybackRate) { if (!player || !player.setPlaybackRate) { return; } player.setPlaybackRate(newPlaybackRate); }; /** * Set current captions track. * * @param {H5P.Video.LabelValue} Captions track to show during playback */ self.setCaptionsTrack = function (track) { if (!track) { player.disableCaptions(); currentTrack = null; } else { player.enableCaptions(track.value + ''); currentTrack = track; } }; /** * Figure out which captions track is currently used. * * @return {H5P.Video.LabelValue} Captions track */ self.getCaptionsTrack = function () { return currentTrack; // No function for getting active caption track? }; // Respond to resize events by setting the player size. self.on('resize', function () { if (!$wrapper.is(':visible')) { return; } if (!player) { // Player isn't created yet. Try again. create(); return; } // Use as much space as possible $wrapper.css({ width: '100%', height: '100%' }); var width = $wrapper[0].clientWidth; var height = options.fit ? $wrapper[0].clientHeight : (width * (9/16)); // Set size $wrapper.css({ width: width + 'px', height: height + 'px' }); const $iframe = $placeholder.children('iframe'); if ($iframe.length) { $iframe.attr('width', width); $iframe.attr('height', height); } }); let currentTrack; } /** * Check to see if we can play any of the given sources. * * @public * @static * @param {Array} sources * @returns {Boolean} */ Panopto.canPlay = function (sources) { return getId(sources[0].path); }; /** * Find id of YouTube video from given URL. * * @private * @param {String} url * @returns {String} Panopto video identifier */ var getId = function (url) { const matches = url.match(/^[^\/]+:\/\/([^\/]*panopto\.[^\/]+)\/Panopto\/.+\?id=(.+)$/); if (matches && matches.length === 3) { return [matches[1], matches[2]]; } }; /** * Load the IFrame Player API asynchronously. */ var loadAPI = function (loaded) { if (window.onPanoptoEmbedApiReady !== undefined) { // Someone else is loading, hook in var original = window.onPanoptoEmbedApiReady; window.onPanoptoEmbedApiReady = function (id) { loaded(id); original(id); }; } else { // Load the API our self var tag = document.createElement('script'); tag.src = 'https://developers.panopto.com/scripts/embedapi.min.js'; var firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); window.onPanoptoEmbedApiReady = loaded; } }; /** @private */ var numInstances = 0; return Panopto; })(H5P.jQuery); // Register video handler H5P.videoHandlers = H5P.videoHandlers || []; H5P.videoHandlers.push(H5P.VideoPanopto); ; /** @namespace H5P */ H5P.VideoHtml5 = (function ($) { /** * HTML5 video player for H5P. * * @class * @param {Array} sources Video files to use * @param {Object} options Settings for the player * @param {Object} l10n Localization strings */ function Html5(sources, options, l10n) { var self = this; /** * Small helper to ensure all video sources get the same cache buster. * * @private * @param {Object} source * @return {string} */ const getCrossOriginPath = function (source) { let path = H5P.getPath(source.path, self.contentId); if (video.crossOrigin !== null && H5P.addQueryParameter && H5PIntegration.crossoriginCacheBuster) { path = H5P.addQueryParameter(path, H5PIntegration.crossoriginCacheBuster); } return path }; /** * Register track to video * * @param {Object} trackData Track object * @param {string} trackData.kind Kind of track * @param {Object} trackData.track Source path * @param {string} [trackData.label] Label of track * @param {string} [trackData.srcLang] Language code */ const addTrack = function (trackData) { // Skip invalid tracks if (!trackData.kind || !trackData.track.path) { return; } var track = document.createElement('track'); track.kind = trackData.kind; track.src = getCrossOriginPath(trackData.track); // Uses same crossOrigin as parent. You cannot mix. if (trackData.label) { track.label = trackData.label; } if (trackData.srcLang) { track.srcLang = trackData.srcLang; } return track; }; /** * Small helper to set the inital video source. * Useful if some of the loading happens asynchronously. * NOTE: Setting the crossOrigin must happen before any of the * sources(poster, tracks etc.) are loaded * * @private */ const setInitialSource = function () { if (qualities[currentQuality] === undefined) { return; } if (H5P.setSource !== undefined) { H5P.setSource(video, qualities[currentQuality].source, self.contentId) } else { // Backwards compatibility (H5P < v1.22) const srcPath = H5P.getPath(qualities[currentQuality].source.path, self.contentId); if (H5P.getCrossOrigin !== undefined) { var crossOrigin = H5P.getCrossOrigin(srcPath); video.setAttribute('crossorigin', crossOrigin !== null ? crossOrigin : 'anonymous'); } video.src = srcPath; } // Add poster if provided if (options.poster) { video.poster = getCrossOriginPath(options.poster); // Uses same crossOrigin as parent. You cannot mix. } // Register tracks options.tracks.forEach(function (track, i) { var trackElement = addTrack(track); if (i === 0) { trackElement.default = true; } if (trackElement) { video.appendChild(trackElement); } }); }; /** * Displayed when the video is buffering * @private */ var $throbber = $('
', { 'class': 'h5p-video-loading' }); /** * Used to display error messages * @private */ var $error = $('
', { 'class': 'h5p-video-error' }); /** * Keep track of current state when changing quality. * @private */ var stateBeforeChangingQuality; var currentTimeBeforeChangingQuality; /** * Avoids firing the same event twice. * @private */ var lastState; /** * Keeps track whether or not the video has been loaded. * @private */ var isLoaded = false; /** * * @private */ var playbackRate = 1; var skipRateChange = false; // Create player var video = document.createElement('video'); // Sort sources into qualities var qualities = getQualities(sources, video); var currentQuality; numQualities = 0; for (let quality in qualities) { numQualities++; } if (numQualities > 1 && H5P.VideoHtml5.getExternalQuality !== undefined) { H5P.VideoHtml5.getExternalQuality(sources, function (chosenQuality) { if (qualities[chosenQuality] !== undefined) { currentQuality = chosenQuality; } setInitialSource(); }); } else { // Select quality and source currentQuality = getPreferredQuality(); if (currentQuality === undefined || qualities[currentQuality] === undefined) { // No preferred quality, pick the first. for (currentQuality in qualities) { if (qualities.hasOwnProperty(currentQuality)) { break; } } } setInitialSource(); } // Setting webkit-playsinline, which makes iOS 10 beeing able to play video // inside browser. video.setAttribute('webkit-playsinline', ''); video.setAttribute('playsinline', ''); video.setAttribute('preload', 'metadata'); // Remove buttons in Chrome's video player: let controlsList = 'nodownload'; if (options.disableFullscreen) { controlsList += ' nofullscreen'; } if (options.disableRemotePlayback) { controlsList += ' noremoteplayback'; } video.setAttribute('controlsList', controlsList); // Remove picture in picture as it interfers with other video players video.disablePictureInPicture = true; // Set options video.disableRemotePlayback = (options.disableRemotePlayback ? true : false); video.controls = (options.controls ? true : false); // Hardcoded autoplay to false to avoid playing videos on init video.autoplay = false; video.loop = (options.loop ? true : false); video.className = 'h5p-video'; video.style.display = 'block'; if (options.fit) { // Style is used since attributes with relative sizes aren't supported by IE9. video.style.width = '100%'; video.style.height = '100%'; } /** * Helps registering events. * * @private * @param {String} native Event name * @param {String} h5p Event name * @param {String} [arg] Optional argument */ var mapEvent = function (native, h5p, arg) { video.addEventListener(native, function () { switch (h5p) { case 'stateChange': if (lastState === arg) { return; // Avoid firing event twice. } var validStartTime = options.startAt && options.startAt > 0; if (arg === H5P.Video.PLAYING && validStartTime) { video.currentTime = options.startAt; delete options.startAt; } break; case 'loaded': isLoaded = true; if (stateBeforeChangingQuality !== undefined) { return; // Avoid loaded event when changing quality. } // Remove any errors if ($error.is(':visible')) { $error.remove(); } if (OLD_ANDROID_FIX) { var andLoaded = function () { video.removeEventListener('durationchange', andLoaded, false); // On Android seeking isn't ready until after play. self.trigger(h5p); }; video.addEventListener('durationchange', andLoaded, false); return; } break; case 'error': // Handle error and get message. arg = error(arguments[0], arguments[1]); break; case 'playbackRateChange': // Fix for keeping playback rate in IE11 if (skipRateChange) { skipRateChange = false; return; // Avoid firing event when changing back } if (H5P.Video.IE11_PLAYBACK_RATE_FIX && playbackRate != video.playbackRate) { // Intentional // Prevent change in playback rate not triggered by the user video.playbackRate = playbackRate; skipRateChange = true; return; } // End IE11 fix arg = self.getPlaybackRate(); break; } self.trigger(h5p, arg); }, false); }; /** * Handle errors from the video player. * * @private * @param {Object} code Error * @param {String} [message] * @returns {String} Human readable error message. */ var error = function (code, message) { if (code instanceof Event) { // No error code if (!code.target.error) { return ''; } switch (code.target.error.code) { case MediaError.MEDIA_ERR_ABORTED: message = l10n.aborted; break; case MediaError.MEDIA_ERR_NETWORK: message = l10n.networkFailure; break; case MediaError.MEDIA_ERR_DECODE: message = l10n.cannotDecode; break; case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: message = l10n.formatNotSupported; break; case MediaError.MEDIA_ERR_ENCRYPTED: message = l10n.mediaEncrypted; break; } } if (!message) { message = l10n.unknownError; } // Hide throbber $throbber.remove(); // Display error message to user $error.text(message).insertAfter(video); // Pass message to our error event return message; }; /** * Appends the video player to the DOM. * * @public * @param {jQuery} $container */ self.appendTo = function ($container) { $container.append(video); }; /** * Get list of available qualities. Not available until after play. * * @public * @returns {Array} */ self.getQualities = function () { // Create reverse list var options = []; for (var q in qualities) { if (qualities.hasOwnProperty(q)) { options.splice(0, 0, { name: q, label: qualities[q].label }); } } if (options.length < 2) { // Do not return if only one quality. return; } return options; }; /** * Get current playback quality. Not available until after play. * * @public * @returns {String} */ self.getQuality = function () { return currentQuality; }; /** * Set current playback quality. Not available until after play. * Listen to event "qualityChange" to check if successful. * * @public * @params {String} [quality] */ self.setQuality = function (quality) { if (qualities[quality] === undefined || quality === currentQuality) { return; // Invalid quality } // Keep track of last choice setPreferredQuality(quality); // Avoid multiple loaded events if changing quality multiple times. if (!stateBeforeChangingQuality) { // Keep track of last state stateBeforeChangingQuality = lastState; // Keep track of current time currentTimeBeforeChangingQuality = video.currentTime; // Seek and start video again after loading. var loaded = function () { video.removeEventListener('loadedmetadata', loaded, false); if (OLD_ANDROID_FIX) { var andLoaded = function () { video.removeEventListener('durationchange', andLoaded, false); // On Android seeking isn't ready until after play. self.seek(currentTimeBeforeChangingQuality); }; video.addEventListener('durationchange', andLoaded, false); } else { // Seek to current time. self.seek(currentTimeBeforeChangingQuality); } // Always play to get image. video.play(); if (stateBeforeChangingQuality !== H5P.Video.PLAYING) { // Do not resume playing video.pause(); } // Done changing quality stateBeforeChangingQuality = undefined; // Remove any errors if ($error.is(':visible')) { $error.remove(); } }; video.addEventListener('loadedmetadata', loaded, false); } // Keep track of current quality currentQuality = quality; self.trigger('qualityChange', currentQuality); // Display throbber self.trigger('stateChange', H5P.Video.BUFFERING); // Change source video.src = getCrossOriginPath(qualities[quality].source); // (iPad does not support #t=). // Note: Optional tracks use same crossOrigin as the original. You cannot mix. // Remove poster so it will not show during quality change video.removeAttribute('poster'); }; /** * Starts the video. * * @public * @return {Promise|undefined} May return a Promise that resolves when * play has been processed. */ self.play = function () { if ($error.is(':visible')) { return; } if (!isLoaded) { // Make sure video is loaded before playing video.load(); } return video.play(); }; /** * Pauses the video. * * @public */ self.pause = function () { video.pause(); }; /** * Seek video to given time. * * @public * @param {Number} time */ self.seek = function (time) { if (lastState === undefined) { // Make sure we always play before we seek to get an image. // If not iOS devices will reset currentTime when pressing play. video.play(); video.pause(); } video.currentTime = time; }; /** * Get elapsed time since video beginning. * * @public * @returns {Number} */ self.getCurrentTime = function () { return video.currentTime; }; /** * Get total video duration time. * * @public * @returns {Number} */ self.getDuration = function () { if (isNaN(video.duration)) { return; } return video.duration; }; /** * Get percentage of video that is buffered. * * @public * @returns {Number} Between 0 and 100 */ self.getBuffered = function () { // Find buffer currently playing from var buffered = 0; for (var i = 0; i < video.buffered.length; i++) { var from = video.buffered.start(i); var to = video.buffered.end(i); if (video.currentTime > from && video.currentTime < to) { buffered = to; break; } } // To percentage return buffered ? (buffered / video.duration) * 100 : 0; }; /** * Turn off video sound. * * @public */ self.mute = function () { video.muted = true; }; /** * Turn on video sound. * * @public */ self.unMute = function () { video.muted = false; }; /** * Check if video sound is turned on or off. * * @public * @returns {Boolean} */ self.isMuted = function () { return video.muted; }; /** * Returns the video sound level. * * @public * @returns {Number} Between 0 and 100. */ self.getVolume = function () { return video.volume * 100; }; /** * Set video sound level. * * @public * @param {Number} level Between 0 and 100. */ self.setVolume = function (level) { video.volume = level / 100; }; /** * Get list of available playback rates. * * @public * @returns {Array} available playback rates */ self.getPlaybackRates = function () { /* * not sure if there's a common rule about determining good speeds * using Google's standard options via a constant for setting */ var playbackRates = PLAYBACK_RATES; return playbackRates; }; /** * Get current playback rate. * * @public * @returns {Number} such as 0.25, 0.5, 1, 1.25, 1.5 and 2 */ self.getPlaybackRate = function () { return video.playbackRate; }; /** * Set current playback rate. * Listen to event "playbackRateChange" to check if successful. * * @public * @params {Number} suggested rate that may be rounded to supported values */ self.setPlaybackRate = function (newPlaybackRate) { playbackRate = newPlaybackRate; video.playbackRate = newPlaybackRate; }; /** * Set current captions track. * * @param {H5P.Video.LabelValue} Captions track to show during playback */ self.setCaptionsTrack = function (track) { for (var i = 0; i < video.textTracks.length; i++) { video.textTracks[i].mode = (track && track.value === i ? 'showing' : 'disabled'); } }; /** * Figure out which captions track is currently used. * * @return {H5P.Video.LabelValue} Captions track */ self.getCaptionsTrack = function () { for (var i = 0; i < video.textTracks.length; i++) { if (video.textTracks[i].mode === 'showing') { return new H5P.Video.LabelValue(video.textTracks[i].label, i); } } return null; }; // Register event listeners mapEvent('ended', 'stateChange', H5P.Video.ENDED); mapEvent('playing', 'stateChange', H5P.Video.PLAYING); mapEvent('pause', 'stateChange', H5P.Video.PAUSED); mapEvent('waiting', 'stateChange', H5P.Video.BUFFERING); mapEvent('loadedmetadata', 'loaded'); mapEvent('canplay', 'canplay'); mapEvent('error', 'error'); mapEvent('ratechange', 'playbackRateChange'); if (!video.controls) { // Disable context menu(right click) to prevent controls. video.addEventListener('contextmenu', function (event) { event.preventDefault(); }, false); } // Display throbber when buffering/loading video. self.on('stateChange', function (event) { var state = event.data; lastState = state; if (state === H5P.Video.BUFFERING) { $throbber.insertAfter(video); } else { $throbber.remove(); } }); // Load captions after the video is loaded self.on('loaded', function () { nextTick(function () { var textTracks = []; for (var i = 0; i < video.textTracks.length; i++) { textTracks.push(new H5P.Video.LabelValue(video.textTracks[i].label, i)); } if (textTracks.length) { self.trigger('captions', textTracks); } }); }); // Alternative to 'canplay' event /*self.on('resize', function () { if (video.offsetParent === null) { return; } video.style.width = '100%'; video.style.height = '100%'; var width = video.clientWidth; var height = options.fit ? video.clientHeight : (width * (video.videoHeight / video.videoWidth)); video.style.width = width + 'px'; video.style.height = height + 'px'; });*/ // Video controls are ready nextTick(function () { self.trigger('ready'); }); } /** * Check to see if we can play any of the given sources. * * @public * @static * @param {Array} sources * @returns {Boolean} */ Html5.canPlay = function (sources) { var video = document.createElement('video'); if (video.canPlayType === undefined) { return false; // Not supported } // Cycle through sources for (var i = 0; i < sources.length; i++) { var type = getType(sources[i]); if (type && video.canPlayType(type) !== '') { // We should be able to play this return true; } } return false; }; /** * Find source type. * * @private * @param {Object} source * @returns {String} */ var getType = function (source) { var type = source.mime; if (!type) { // Try to get type from URL var matches = source.path.match(/\.(\w+)$/); if (matches && matches[1]) { type = 'video/' + matches[1]; } } if (type && source.codecs) { // Add codecs type += '; codecs="' + source.codecs + '"'; } return type; }; /** * Sort sources into qualities. * * @private * @static * @param {Array} sources * @param {Object} video * @returns {Object} Quality mapping */ var getQualities = function (sources, video) { var qualities = {}; var qualityIndex = 1; var lastQuality; // Cycle through sources for (var i = 0; i < sources.length; i++) { var source = sources[i]; // Find and update type. var type = source.type = getType(source); // Check if we support this type var isPlayable = type && (type === 'video/unknown' || video.canPlayType(type) !== ''); if (!isPlayable) { continue; // We cannot play this source } if (source.quality === undefined) { /** * No quality metadata. Create a quality tag to separate multiple sources of the same type, * e.g. if two mp4 files with different quality has been uploaded */ if (lastQuality === undefined || qualities[lastQuality].source.type === type) { // Create a new quality tag source.quality = { name: 'q' + qualityIndex, label: (source.metadata && source.metadata.qualityName) ? source.metadata.qualityName : 'Quality ' + qualityIndex // TODO: l10n }; qualityIndex++; } else { /** * Assumes quality already exists in a different format. * Uses existing label for this quality. */ source.quality = qualities[lastQuality].source.quality; } } // Log last quality lastQuality = source.quality.name; // Look to see if quality exists var quality = qualities[lastQuality]; if (quality) { // We have a source with this quality. Check if we have a better format. if (source.mime.split('/')[1] === PREFERRED_FORMAT) { quality.source = source; } } else { // Add new source with quality. qualities[source.quality.name] = { label: source.quality.label, source: source }; } } return qualities; }; /** * Set preferred video quality. * * @private * @static * @param {String} quality Index of preferred quality */ var setPreferredQuality = function (quality) { try { localStorage.setItem('h5pVideoQuality', quality); } catch (err) { console.warn('Unable to set preferred video quality, localStorage is not available.'); } }; /** * Get preferred video quality. * * @private * @static * @returns {String} Index of preferred quality */ var getPreferredQuality = function () { // First check localStorage let quality; try { quality = localStorage.getItem('h5pVideoQuality'); } catch (err) { console.warn('Unable to retrieve preferred video quality from localStorage.'); } if (!quality) { try { // The fallback to old cookie solution var settings = document.cookie.split(';'); for (var i = 0; i < settings.length; i++) { var setting = settings[i].split('='); if (setting[0] === 'H5PVideoQuality') { quality = setting[1]; break; } } } catch (err) { console.warn('Unable to retrieve preferred video quality from cookie.'); } } return quality; }; /** * Helps schedule a task for the next tick. * @param {function} task */ var nextTick = function (task) { setTimeout(task, 0); }; /** @constant {Boolean} */ var OLD_ANDROID_FIX = false; /** @constant {Boolean} */ var PREFERRED_FORMAT = 'mp4'; /** @constant {Object} */ var PLAYBACK_RATES = [0.25, 0.5, 1, 1.25, 1.5, 2]; if (navigator.userAgent.indexOf('Android') !== -1) { // We have Android, check version. var version = navigator.userAgent.match(/AppleWebKit\/(\d+\.?\d*)/); if (version && version[1] && Number(version[1]) <= 534.30) { // Include fix for devices running the native Android browser. // (We don't know when video was fixed, so the number is just the lastest // native android browser we found.) OLD_ANDROID_FIX = true; } } else { if (navigator.userAgent.indexOf('Chrome') !== -1) { // If we're using chrome on a device that isn't Android, prefer the webm // format. This is because Chrome has trouble with some mp4 codecs. PREFERRED_FORMAT = 'webm'; } } return Html5; })(H5P.jQuery); // Register video handler H5P.videoHandlers = H5P.videoHandlers || []; H5P.videoHandlers.push(H5P.VideoHtml5); ; /** @namespace H5P */ H5P.Video = (function ($, ContentCopyrights, MediaCopyright, handlers) { /** * The ultimate H5P video player! * * @class * @param {Object} parameters Options for this library. * @param {Object} parameters.visuals Visual options * @param {Object} parameters.playback Playback options * @param {Object} parameters.a11y Accessibility options * @param {Boolean} [parameters.startAt] Start time of video * @param {Number} id Content identifier */ function Video(parameters, id) { var self = this; self.contentId = id; // Ref youtube.js - ipad & youtube - issue self.pressToPlay = false; // Reference to the handler var handlerName = ''; // Initialize event inheritance H5P.EventDispatcher.call(self); // Default language localization parameters = $.extend(true, parameters, { l10n: { name: 'Video', loading: 'Video player loading...', noPlayers: 'Found no video players that supports the given video format.', noSources: 'Video source is missing.', aborted: 'Media playback has been aborted.', networkFailure: 'Network failure.', cannotDecode: 'Unable to decode media.', formatNotSupported: 'Video format not supported.', mediaEncrypted: 'Media encrypted.', unknownError: 'Unknown error.', vimeoPasswordError: 'Password-protected Vimeo videos are not supported.', vimeoPrivacyError: 'The Vimeo video cannot be used due to its privacy settings.', vimeoLoadingError: 'The Vimeo video could not be loaded.', invalidYtId: 'Invalid YouTube ID.', unknownYtId: 'Unable to find video with the given YouTube ID.', restrictedYt: 'The owner of this video does not allow it to be embedded.' } }); parameters.a11y = parameters.a11y || []; parameters.playback = parameters.playback || {}; parameters.visuals = $.extend(true, parameters.visuals, { disableFullscreen: false }); /** @private */ var sources = []; if (parameters.sources) { for (var i = 0; i < parameters.sources.length; i++) { // Clone to avoid changing of parameters. var source = $.extend(true, {}, parameters.sources[i]); // Create working URL without html entities. source.path = $cleaner.html(source.path).text(); sources.push(source); } } /** @private */ var tracks = []; parameters.a11y.forEach(function (track) { // Clone to avoid changing of parameters. var clone = $.extend(true, {}, track); // Create working URL without html entities if (clone.track && clone.track.path) { clone.track.path = $cleaner.html(clone.track.path).text(); tracks.push(clone); } }); /** * Handle autoplay. If autoplay is disabled, it will still autopause when * video is not visible. * * @param {*} $container */ const handleAutoPlayPause = function ($container) { // Keep the current state let state; self.on('stateChange', function(event) { state = event.data; }); // Keep record of autopauses. // I.e: we don't wanna autoplay if the user has excplicitly paused. self.autoPaused = !self.pressToPlay; new IntersectionObserver(function (entries) { const entry = entries[0]; // This video element became visible if (entry.isIntersecting) { // Autoplay if autoplay is enabled and it was not explicitly // paused by a user if (parameters.playback.autoplay && self.autoPaused) { self.autoPaused = false; self.play(); } } else if (state !== Video.PAUSED) { self.autoPaused = true; self.pause(); } }, { root: null, threshold: [0, 1] // Get events when it is shown and hidden }).observe($container.get(0)); }; /** * Attaches the video handler to the given container. * Inserts text if no handler is found. * * @public * @param {jQuery} $container */ self.attach = function ($container) { $container.addClass('h5p-video').html(''); if (self.appendTo !== undefined) { self.appendTo($container); // Avoid autoplaying in authoring tool if (window.H5PEditor === undefined) { handleAutoPlayPause($container); } } else if (sources.length) { $container.text(parameters.l10n.noPlayers); } else { $container.text(parameters.l10n.noSources); } }; /** * Get name of the video handler * * @public * @returns {string} */ self.getHandlerName = function() { return handlerName; }; // Resize the video when we know its aspect ratio self.on('loaded', function () { self.trigger('resize'); }); // Find player for video sources if (sources.length) { const options = { controls: parameters.visuals.controls, autoplay: false, loop: parameters.playback.loop, fit: parameters.visuals.fit, poster: parameters.visuals.poster === undefined ? undefined : parameters.visuals.poster, startAt: parameters.startAt || 0, tracks: tracks, disableRemotePlayback: parameters.visuals.disableRemotePlayback === true, disableFullscreen: parameters.visuals.disableFullscreen === true } var html5Handler; for (var i = 0; i < handlers.length; i++) { var handler = handlers[i]; if (handler.canPlay !== undefined && handler.canPlay(sources)) { handler.call(self, sources, options, parameters.l10n); handlerName = handler.name; return; } if (handler === H5P.VideoHtml5) { html5Handler = handler; handlerName = handler.name; } } // Fallback to trying HTML5 player if (html5Handler) { html5Handler.call(self, sources, options, parameters.l10n); } } } // Extends the event dispatcher Video.prototype = Object.create(H5P.EventDispatcher.prototype); Video.prototype.constructor = Video; // Player states /** @constant {Number} */ Video.ENDED = 0; /** @constant {Number} */ Video.PLAYING = 1; /** @constant {Number} */ Video.PAUSED = 2; /** @constant {Number} */ Video.BUFFERING = 3; /** * When video is queued to start * @constant {Number} */ Video.VIDEO_CUED = 5; // Used to convert between html and text, since URLs have html entities. var $cleaner = H5P.jQuery('
'); /** * Help keep track of key value pairs used by the UI. * * @class * @param {string} label * @param {string} value */ Video.LabelValue = function (label, value) { this.label = label; this.value = value; }; /** @constant {Boolean} */ Video.IE11_PLAYBACK_RATE_FIX = (navigator.userAgent.match(/Trident.*rv[ :]*11\./) ? true : false); return Video; })(H5P.jQuery, H5P.ContentCopyrights, H5P.MediaCopyright, H5P.videoHandlers || []); ; var H5P = H5P || {}; /** * Constructor. * * @param {Object} params Options for this library. * @param {Number} id Content identifier * @returns {undefined} */ (function ($) { H5P.Image = function (params, id, extras) { H5P.EventDispatcher.call(this); this.extras = extras; if (params.file === undefined || !(params.file instanceof Object)) { this.placeholder = true; } else { this.source = H5P.getPath(params.file.path, id); this.width = params.file.width; this.height = params.file.height; } this.alt = (!params.decorative && params.alt !== undefined) ? this.stripHTML(this.htmlDecode(params.alt)) : ''; if (params.title !== undefined) { this.title = this.stripHTML(this.htmlDecode(params.title)); } }; H5P.Image.prototype = Object.create(H5P.EventDispatcher.prototype); H5P.Image.prototype.constructor = H5P.Image; /** * Wipe out the content of the wrapper and put our HTML in it. * * @param {jQuery} $wrapper * @returns {undefined} */ H5P.Image.prototype.attach = function ($wrapper) { var self = this; var source = this.source; if (self.$img === undefined) { if(self.placeholder) { self.$img = $('
', { width: '100%', height: '100%', class: 'h5p-placeholder', title: this.title === undefined ? '' : this.title, on: { load: function () { self.trigger('loaded'); } } }); } else { self.$img = $('', { width: '100%', height: '100%', src: source, alt: this.alt, title: this.title === undefined ? '' : this.title, on: { load: function () { self.trigger('loaded'); } } }); } } $wrapper.addClass('h5p-image').html(self.$img); }; /** * Retrieve decoded HTML encoded string. * * @param {string} input HTML encoded string. * @returns {string} Decoded string. */ H5P.Image.prototype.htmlDecode = function (input) { const dparser = new DOMParser().parseFromString(input, 'text/html'); return dparser.documentElement.textContent; }; /** * Retrieve string without HTML tags. * * @param {string} input Input string. * @returns {string} Output string. */ H5P.Image.prototype.stripHTML = function (html) { const div = document.createElement('div'); div.innerHTML = html; return div.textContent || div.innerText || ''; }; return H5P.Image; }(H5P.jQuery)); ; var H5P = H5P || {}; /** * Transition contains helper function relevant for transitioning */ H5P.Transition = (function ($) { /** * @class * @namespace H5P */ Transition = {}; /** * @private */ Transition.transitionEndEventNames = { 'WebkitTransition': 'webkitTransitionEnd', 'transition': 'transitionend', 'MozTransition': 'transitionend', 'OTransition': 'oTransitionEnd', 'msTransition': 'MSTransitionEnd' }; /** * @private */ Transition.cache = []; /** * Get the vendor property name for an event * * @function H5P.Transition.getVendorPropertyName * @static * @private * @param {string} prop Generic property name * @return {string} Vendor specific property name */ Transition.getVendorPropertyName = function (prop) { if (Transition.cache[prop] !== undefined) { return Transition.cache[prop]; } var div = document.createElement('div'); // Handle unprefixed versions (FF16+, for example) if (prop in div.style) { Transition.cache[prop] = prop; } else { var prefixes = ['Moz', 'Webkit', 'O', 'ms']; var prop_ = prop.charAt(0).toUpperCase() + prop.substr(1); if (prop in div.style) { Transition.cache[prop] = prop; } else { for (var i = 0; i < prefixes.length; ++i) { var vendorProp = prefixes[i] + prop_; if (vendorProp in div.style) { Transition.cache[prop] = vendorProp; break; } } } } return Transition.cache[prop]; }; /** * Get the name of the transition end event * * @static * @private * @return {string} description */ Transition.getTransitionEndEventName = function () { return Transition.transitionEndEventNames[Transition.getVendorPropertyName('transition')] || undefined; }; /** * Helper function for listening on transition end events * * @function H5P.Transition.onTransitionEnd * @static * @param {domElement} $element The element which is transitioned * @param {function} callback The callback to be invoked when transition is finished * @param {number} timeout Timeout in milliseconds. Fallback if transition event is never fired */ Transition.onTransitionEnd = function ($element, callback, timeout) { // Fallback on 1 second if transition event is not supported/triggered timeout = timeout || 1000; Transition.transitionEndEventName = Transition.transitionEndEventName || Transition.getTransitionEndEventName(); var callbackCalled = false; var doCallback = function () { if (callbackCalled) { return; } $element.off(Transition.transitionEndEventName, callback); callbackCalled = true; clearTimeout(timer); callback(); }; var timer = setTimeout(function () { doCallback(); }, timeout); $element.on(Transition.transitionEndEventName, function () { doCallback(); }); }; /** * Wait for a transition - when finished, invokes next in line * * @private * * @param {Object[]} transitions Array of transitions * @param {H5P.jQuery} transitions[].$element Dom element transition is performed on * @param {number=} transitions[].timeout Timeout fallback if transition end never is triggered * @param {bool=} transitions[].break If true, sequence breaks after this transition * @param {number} index The index for current transition */ var runSequence = function (transitions, index) { if (index >= transitions.length) { return; } var transition = transitions[index]; H5P.Transition.onTransitionEnd(transition.$element, function () { if (transition.end) { transition.end(); } if (transition.break !== true) { runSequence(transitions, index+1); } }, transition.timeout || undefined); }; /** * Run a sequence of transitions * * @function H5P.Transition.sequence * @static * @param {Object[]} transitions Array of transitions * @param {H5P.jQuery} transitions[].$element Dom element transition is performed on * @param {number=} transitions[].timeout Timeout fallback if transition end never is triggered * @param {bool=} transitions[].break If true, sequence breaks after this transition */ Transition.sequence = function (transitions) { runSequence(transitions, 0); }; return Transition; })(H5P.jQuery); ; /** * Defines the H5P.ImageHotspots class */ H5P.ImageHotspots = (function ($, EventDispatcher) { /** * Default font size * * @constant * @type {number} * @default */ var DEFAULT_FONT_SIZE = 24; /** * Creates a new Image hotspots instance * * @class * @augments H5P.EventDispatcher * @namespace H5P * @param {Object} options * @param {number} id */ function ImageHotspots(options, id) { EventDispatcher.call(this); // Extend defaults with provided options this.options = $.extend(true, {}, { image: null, hotspots: [], hotspotNumberLabel: 'Hotspot #num', closeButtonLabel: 'Close', iconType: 'icon', icon: 'plus' }, options); // Keep provided id. this.id = id; this.isSmallDevice = false; } // Extends the event dispatcher ImageHotspots.prototype = Object.create(EventDispatcher.prototype); ImageHotspots.prototype.constructor = ImageHotspots; /** * Attach function called by H5P framework to insert H5P content into * page * * @public * @param {H5P.jQuery} $container */ ImageHotspots.prototype.attach = function ($container) { var self = this; self.$container = $container; if (this.options.image === null || this.options.image === undefined) { $container.append('
Missing required background image
'); return; } // Need to know since ios uses :hover when clicking on an element if (/(iPad|iPhone|iPod)/g.test( navigator.userAgent ) === false) { $container.addClass('not-an-ios-device'); } $container.addClass('h5p-image-hotspots'); this.$hotspotContainer = $('
', { 'class': 'h5p-image-hotspots-container' }); if (this.options.image && this.options.image.path) { this.$image = $('', { 'class': 'h5p-image-hotspots-background', src: H5P.getPath(this.options.image.path, this.id) }).appendTo(this.$hotspotContainer); // Set alt text of image if (this.options.backgroundImageAltText) { this.$image.attr('alt', this.options.backgroundImageAltText); } else { // Ignore image if no alternative text for assistive technologies this.$image.attr('aria-hidden', true); } } var isSmallDevice = function () { return self.isSmallDevice; }; // Add hotspots var numHotspots = this.options.hotspots.length; this.hotspots = []; this.options.hotspots.sort(function (a, b) { // Sanity checks, move data to the back if invalid var firstIsValid = a.position && a.position.x && a.position.y; var secondIsValid = b.position && b.position.x && b.position.y; if (!firstIsValid) { return 1; } if (!secondIsValid) { return -1; } // Order top-to-bottom, left-to-right if (a.position.y !== b.position.y) { return a.position.y < b.position.y ? -1 : 1; } else { // a and b y position is equal, sort on x return a.position.x < b.position.x ? -1 : 1; } }); for (var i=0; i 1) { this.hotspots[this.hotspots.length - 1].setTrapFocusTo(this.hotspots[0]); this.hotspots[0].setTrapFocusTo(this.hotspots[this.hotspots.length - 1], true); } } else { // Untrap focus this.hotspots[this.hotspots.length - 1].releaseTrapFocus(); this.hotspots[0].releaseTrapFocus(); } }; /** * Handle resizing * @private * @param {Event} [e] * @param {boolean} [e.forceImageHeight] * @param {boolean} [e.decreaseSize] */ ImageHotspots.prototype.resize = function (e) { if (this.options.image === null) { return; } var self = this; self.fullscreenButton = document.querySelector('.h5p-enable-fullscreen'); var containerWidth = self.$container.width(); var containerHeight = self.$container.height(); var width = containerWidth; var height = Math.floor((width/self.options.image.width) * self.options.image.height); var forceImageHeight = e && e.data && e.data.forceImageHeight; // Check if decreasing iframe size var decreaseSize = e && e.data && e.data.decreaseSize; if (!decreaseSize) { self.$container.css('width', ''); } // If fullscreen & standalone if (this.isRoot() && H5P.isFullscreen) { // If fullscreen, we have both a max width and max height. if (!forceImageHeight && height > containerHeight) { height = containerHeight; width = Math.floor((height/self.options.image.height) * self.options.image.width); } // Check if we need to apply semi full screen fix. if (self.$container.is('.h5p-semi-fullscreen')) { // Reset semi fullscreen width self.$container.css('width', ''); // Decrease iframe size if (!decreaseSize) { self.$hotspotContainer.css('width', '10px'); self.$image.css('width', '10px'); // Trigger changes setTimeout(function () { self.trigger('resize', {decreaseSize: true}); }, 200); } // Set width equal to iframe parent width, since iframe content has not been updated yet. var $iframe = $(window.frameElement); if ($iframe) { var $iframeParent = $iframe.parent(); width = $iframeParent.width(); self.$container.css('width', width + 'px'); } } } self.$image.css({ width: width + 'px', height: height + 'px' }); if (!self.initialWidth) { self.initialWidth = self.$container.width(); } self.fontSize = Math.max(DEFAULT_FONT_SIZE, (DEFAULT_FONT_SIZE * (width/self.initialWidth))); self.$hotspotContainer.css({ width: width + 'px', height: height + 'px', fontSize: self.fontSize + 'px' }); self.isSmallDevice = (containerWidth / parseFloat($("body").css("font-size")) < 40); }; ImageHotspots.prototype.pause = function() { this.hotspots.forEach(function(hotspot) { if (hotspot.pause) { hotspot.pause(); } }); }; return ImageHotspots; })(H5P.jQuery, H5P.EventDispatcher); ; /** * Defines the ImageHotspots.Hotspot class */ (function ($, ImageHotspots) { /** * Creates a new Hotspot * * @class * @namespace H5P.ImageHotspots * @param {Object} config * @param {Object} options * @param {number} id * @param {boolean} isSmallDeviceCB * @param {H5P.ImageHotspots} parent */ ImageHotspots.Hotspot = function (config, options, id, isSmallDeviceCB, parent) { var self = this; this.config = config; this.visible = false; this.id = id; this.isSmallDeviceCB = isSmallDeviceCB; this.options = options; this.parent = parent; // A utility variable to check if a Predefined icon or an uploaded image should be used. var iconImageExists = (options.iconImage !== undefined && options.iconType === 'image'); if (this.config.content === undefined || this.config.content.length === 0) { throw new Error('Missing content configuration for hotspot. Please fix in editor.'); } // Check if there is an iconImage that should be used instead of fontawesome icons to determine the html element. this.$element = $(iconImageExists ? '' : '