/** @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; let isLoaded = false; 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; isLoaded = 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) => { if (!player) { return; } currentTime = time; 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; }; /** * Whether the video is loaded. * * @public * @returns {Boolean} True if the video is muted, false otherwise */ self.isLoaded = () => { return isLoaded; }; /** * 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 ratio = 9/16; var $wrapper = $(''); var $placeholder = $('', { text: l10n.loading, html: `${l10n.unknownError}
`; wrapperElement.style.cssText = 'width: null; height: null;'; this.trigger('resize'); this.trigger('error', l10n.unknownError); }, LOADING_TIMEOUT_IN_SECONDS * 1000); }; /** * Appends the video player to the DOM. * * @public * @param {jQuery} $container */ this.appendTo = ($container) => { $container.addClass('h5p-echo').append(wrapperElement); createEchoPlayer(); }; /** * Determine if the video has loaded. * * @public * @returns {Boolean} */ this.isLoaded = () => { return loadingComplete; }; /** * Get list of available qualities. * * @public * @returns {Array} */ this.getQualities = () => { return qualities; }; /** * Get the current quality. * * @public * @returns {String} Current quality identifier */ this.getQuality = () => { return currentQuality; }; /** * Set the playback quality. * * @public * @param {String} quality */ this.setQuality = async (quality) => { this.post('quality', quality); currentQuality = quality; this.trigger('qualityChange', currentQuality); }; /** * Start the video. * * @public */ this.play = () => { if (!player) { this.on('ready', this.play); return; } this.post('play', 0); }; /** * Pause the video. * * @public */ this.pause = () => { // Compensate for Echo360's delayed time updates timelineUpdatesToSkip = 1; this.post('pause', 0); }; /** * Seek video to given time. * * @public * @param {Number} time */ this.seek = (time) => { this.post('seek', time); this.setCurrentTime(time); // Compensate for Echo360's delayed time updates timelineUpdatesToSkip = 1; }; /** * Post a window message to the iframe. * * @public * @param event * @param data */ this.post = (event, data) => { player?.contentWindow?.postMessage( JSON.stringify({ event: event, context: 'Echo360', instanceId: instanceId, data: data }), '*' ); }; /** * Return the current play position. * * @public * @returns {Number} Seconds elapsed since beginning of video */ this.getCurrentTime = () => { return currentTime; }; /** * Set current time. * @param {number} timeS Time in seconds. */ this.setCurrentTime = (timeS) => { currentTime = timeS; } /** * Return the video duration. * * @public * @returns {?Number} Video duration in seconds */ this.getDuration = () => { if (duration > 0) { return duration; } return null; }; /** * Get percentage of video that is buffered. * * @public * @returns {Number} Between 0 and 100 */ this.getBuffered = () => { return buffered; }; /** * Mute the video. * * @public */ this.mute = () => { this.post('mute', 0); isMuted = true; }; /** * Unmute the video. * * @public */ this.unMute = () => { this.post('unmute', 0); isMuted = false; }; /** * Whether the video is muted. * * @public * @returns {Boolean} True if the video is muted, false otherwise */ this.isMuted = () => { return isMuted; }; /** * Get the video player's current sound volume. * * @public * @returns {Number} Between 0 and 100. */ this.getVolume = () => { return volume; }; /** * Set the video player's sound volume. * * @public * @param {Number} level */ this.setVolume = (level) => { this.post('volume', level); volume = level; }; /** * Get list of available playback rates. * * @public * @returns {Array} Available playback rates */ this.getPlaybackRates = () => { return [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; }; /** * Get the current playback rate. * * @public * @returns {Number} e.g. 0.5, 1, 1.5 or 2 */ this.getPlaybackRate = () => { return playbackRate; }; /** * Set the current playback rate. * * @public * @param {Number} rate Must be one of available rates from getPlaybackRates */ this.setPlaybackRate = async (rate) => { const echoRate = parseFloat(rate); this.post('playbackrate', echoRate); playbackRate = rate; this.trigger('playbackRateChange', rate); }; /** * Set current captions track. * * @public * @param {H5P.Video.LabelValue} track Captions to display */ this.setCaptionsTrack = (track) => { const echoCaption = trackOptions.find( (trackItem) => track?.value === trackItem.value ); trackOptions.forEach(trackItem => { trackItem.mode = (trackItem === echoCaption) ? 'showing' : 'disabled'; }); this.post('captions', echoCaption ? echoCaption.value : 'off'); }; /** * Get current captions track. * * @public * @returns {H5P.Video.LabelValue|null} Current captions track. */ this.getCaptionsTrack = () => { return trackOptions.find( (trackItem) => trackItem.mode === 'showing' ) ?? null; }; this.on('resize', () => { if (failedLoading || !isNodeVisible(wrapperElement)) { return; } if (player === undefined) { // Player isn't created yet. Try again. createEchoPlayer(); return; } // Use as much space as possible wrapperElement.style.cssText = 'width: 100%; height: 100%;'; const width = wrapperElement.clientWidth; const height = options.fit ? wrapperElement.clientHeight : (width * (ratio)); // Validate height before setting if (height > 0) { // Set size wrapperElement.style.cssText = 'width: ' + width + 'px; height: ' + height + 'px;'; } }); } /** * Find id of video from given URL. * * @private * @param {String} url * @returns {String} Echo video identifier */ const getId = (url) => { const matches = url.match(/^[^/]+:\/\/(echo360[^/]+)\/media\/([^/]+)\/h5p.*$/i); if (matches && matches.length === 3) { return [matches[2], matches[2]]; } }; /** * Check to see if we can play any of the given sources. * * @public * @static * @param {Array} sources * @returns {Boolean} */ EchoPlayer.canPlay = (sources) => { return getId(sources[0].path); }; return EchoPlayer; })(H5P.jQuery); // Register video handler H5P.videoHandlers = H5P.videoHandlers || []; H5P.videoHandlers.push(H5P.VideoEchoVideo); ; /** @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 '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; } if (video.poster) { $(video).one('play', function () { self.seek(self.getCurrentTime() || options.startAt); }); } else { self.seek(options.startAt); } 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(); video.addEventListener('loadeddata', function() { video.play(); }, false); } else { 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) { video.currentTime = time; // Use canplaythrough for IOs devices // Use loadedmetadata for all other devices. const eventName = navigator.userAgent.match(/iPad|iPod|iPhone/i) ? "canplaythrough" : "loadedmetadata"; function seekTo() { video.currentTime = time; video.removeEventListener(eventName, seekTo); }; if (video.readyState === 4) { seekTo(); } else { video.addEventListener(eventName, seekTo); } }; /** * 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 video is loaded and ready play. * * @public * @param {Boolean} */ self.isLoaded = function () { return isLoaded; }; } /** * 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 * @param {Object} [extras] Extra parameters. */ function Video(parameters, id, extras = {}) { var self = this; self.oldTime = extras.previousState?.time; self.contentId = id; self.WAS_RESET = false; self.startAt = parameters.startAt || 0; // 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, { disableFullscreen: false }, parameters.visuals ); /** @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 && state !== Video.ENDED) { 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; }; /** * @public * Get current state for resume support. * * @returns {object} Current state. */ self.getCurrentState = function () { if (self.getCurrentTime) { return { time: self.getCurrentTime() || self.oldTime, }; } }; /** * The two functions below needs to be defined in this base class, * since it is used in this class even if no handler was found. */ self.seek = () => {}; self.pause = () => {}; /** * @public * Reset current state (time). * */ self.resetTask = function () { delete self.oldTime; self.resetPlayback(parameters.startAt || 0); }; /** * Default implementation of resetPlayback. May be overridden by sub classes. * * @param {*} startAt */ self.resetPlayback = startAt => { self.seek(startAt); self.pause(); self.WAS_RESET = true; }; // Resize the video when we know its aspect ratio self.on('loaded', function () { self.trigger('resize'); // reset time if wasn't done immediately if (self.WAS_RESET) { self.seek(parameters.startAt || 0); if (!parameters.playback.autoplay) { self.pause(); } self.WAS_RESET = false; } }); // Find player for video sources if (sources.length) { const options = { controls: parameters.visuals.controls, autoplay: parameters.playback.autoplay, loop: parameters.playback.loop, fit: parameters.visuals.fit, poster: parameters.visuals.poster === undefined ? undefined : parameters.visuals.poster, tracks: tracks, disableRemotePlayback: parameters.visuals.disableRemotePlayback === true, disableFullscreen: parameters.visuals.disableFullscreen === true, deactivateSound: parameters.playback.deactivateSound, } if (!self.WAS_RESET) { options.startAt = self.oldTime !== undefined ? self.oldTime : (parameters.startAt || 0); } 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; }; /** * Determine whether video can be autoplayed. * @returns {Promise