/*! * Copyright (C) InnoCraft Ltd - All rights reserved. * * All information contained herein is, and remains the property of InnoCraft Ltd. * * @link https://www.innocraft.com/ * @license For license details see https://www.innocraft.com/license */ /** * To minify this version call * cat tracker.js | java -jar ../../js/yuicompressor-2.4.8.jar --type js --line-break 1000 | sed 's/^[/][*]/\/*!/' > tracker.min.js */ (function () { var timeWhenScriptLoaded = new Date().getTime(); var timeFirstTrackingRequest = null; var debugMode = false; var pingIntervalInSeconds = 10; var usesCustomInterval = false; var isMediaTrackingEnabled = true; var customPiwikTrackers = null; var stopTrackingAfterXMs = 1000 * 60 * 60 * 3; // we stop after 3 hours var documentAlias = document; var windowAlias = window; var numMediaPlaysTotal = 0; var numMediaPlaysTotalOffScreen = 0; var isPluginInitialized = false; var maxNoOfEventsAllowedPerTracker = {play:50, pause: 25, resume:25, finish:50, seek:50}; var initialEventsCount = function() { return {play:0, pause: 0, resume:0, finish:0, seek:0}; }; var totalEventsAllowedPerPagePerTracker = {play:50, pause: 100, resume:100, finish:50, seek:100}; var defaultEventLimitForUnknownTrackingEvents = 25; var isRateLimitEnabled = true; var mediaTitleFallback = function (){ return ''; }; var mediaTrackerInstances = []; function getJson() { if (typeof Piwik === 'object' && typeof Piwik.JSON === 'object') { return Piwik.JSON; } else if (windowAlias.JSON && windowAlias.JSON.parse && windowAlias.JSON.stringify) { return windowAlias.JSON; } else if (typeof windowAlias.JSON2 === 'object' && windowAlias.JSON2.parse && windowAlias.JSON2.stringify) { return windowAlias.JSON2; } else { return {parse: function () { return {}; }, stringify: function () { return ''; }} } } var isFirstPlay = true; function logConsoleMessage() { if (debugMode && 'undefined' !== typeof console && console && console.debug) { console.debug.apply(console, arguments); } } function isArray(variable) { return typeof variable === 'object' && typeof variable.length === 'number'; } function isOpenCast() { return documentAlias.getElementById('engage_video') && documentAlias.getElementById('videoDisplay1_wrapper'); } function hasJwPlayer() { return 'function' === typeof jwplayer; } function hasFlowPlayer() { return 'function' === typeof flowplayer; } function setDefaultFallbackTitle(node, tracker) { if (!tracker.getMediaTitle() && 'function' === typeof mediaTitleFallback) { var fallbackTitle = mediaTitleFallback(node); if (fallbackTitle) { tracker.setMediaTitle(fallbackTitle); } } } // first letter is upper as we use it for event tracking as in MediaAudio, MediaVideo var mediaType = {AUDIO: 'Audio', VIDEO: 'Video'}; var urlHelper = { getLocation: function () { var location = this.location || windowAlias.location; if (!location.origin) { location.origin = location.protocol + "//" + location.hostname + (location.port ? ':' + location.port: ''); } return location; }, setLocation: function (location) { this.location = location; }, makeUrlAbsolute: function (url) { if ((!url || String(url) !== url) && url !== '') { // it has to be a string return url; } if (url.indexOf('//') === 0) { // eg url without protocol //innocraft.com/movie.mp4 return this.getLocation().protocol + url; } if (url.indexOf('://') !== -1) { // eg absolute url http://innocraft.com/movie.mp4 return url; } if (url.indexOf('/') === 0) { // eg url without domain /movie.mp4 return this.getLocation().origin + url; } if (url.indexOf('#') === 0 || url.indexOf('?') === 0) { // eg only query or hash ?movie=movie.mp4 or #movie return this.getLocation().origin + this.getLocation().pathname + url; } if ('' === url) { return this.getLocation().href; } // eg relative path movie.mp4 var regexToMatchDir = '(.*\/)'; var basePath = this.getLocation().origin + this.getLocation().pathname.match(new RegExp(regexToMatchDir))[0]; return basePath + url; } }; var utils = { getCurrentTime: function () { return new Date().getTime(); }, roundTimeToSeconds: function (timeInMs) { return Math.round(timeInMs / 1000); }, isNumber: function (text) { return !isNaN(text); }, isArray: function (variable) { return typeof variable === 'object' && variable !== null && typeof variable.length === 'number'; }, indexOfArray: function (anArray, element) { if (!anArray) { return -1; } if (anArray.indexOf) { return anArray.indexOf(element); } if (!this.isArray(anArray)) { return -1; } for (var i = 0; i < anArray.length; i++) { if (anArray[i] === element) { return i; } } return -1; }, getTimeScriptLoaded: function (text) { return timeWhenScriptLoaded; }, generateUniqueId: function () { var id = ''; var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charLen = chars.length; for (var i = 0; i < 6; i++) { id += chars.charAt(Math.floor(Math.random() * charLen)); } return id; }, trim: function (text) { if (text && String(text) === text) { return text.replace(/^\s+|\s+$/g, ''); } return text; }, getQueryParameter: function (url, parameter) { var regexp = new RegExp('[?&]' + parameter + '(=([^&#]*)|&|#|$)'); var matches = regexp.exec(url); if (!matches) { return null; } if (!matches[2]) { return ''; } var value = matches[2].replace(/\+/g, " "); return decodeURIComponent(value); }, isDocumentOffScreen: function () { return documentAlias && 'undefined' !== documentAlias.hidden && documentAlias.hidden; }, roundUp: function (number, round) { if (number !== null && number !== false && this.isNumber(number)) { return Math.ceil(number / round) * round; } } }; var element = { getAttribute: function (node, attributeName) { if (node && node.getAttribute && attributeName) { return node.getAttribute(attributeName); } return null; }, setAttribute: function (node, attributeName, attributeValue) { if (node && node.setAttribute) { node.setAttribute(attributeName, attributeValue); } }, isMediaIgnored: function (node) { var ignore = element.getAttribute(node, 'data-piwik-ignore'); if (!!ignore || ignore === '') { return true; } ignore = element.getAttribute(node, 'data-matomo-ignore'); if (!!ignore || ignore === '') { return true; } return false; }, getMediaResource: function (node, defaultResource) { var src = element.getAttribute(node, 'data-matomo-resource'); if (src) { return src; } src = element.getAttribute(node, 'data-piwik-resource'); if (src) { return src; } src = element.getAttribute(node, 'src'); if (src) { return src; } return defaultResource; }, getMediaTitle: function (node) { var title = element.getAttribute(node, 'data-matomo-title'); if (!title) { title = element.getAttribute(node, 'data-piwik-title'); } if (!title) { title = element.getAttribute(node, 'title'); } if (!title) { title = element.getAttribute(node, 'alt'); } return title; }, hasCssClass: function (node, theClass) { if (node && node.className) { var classes = ('' + node.className).split(' '); for (var i = 0; i < classes.length; i++) { if (classes[i] === theClass) { return true; } } } return false; }, getFirstParentWithClass: function (node, theClass, maxLevels) { if (maxLevels <= 0 || !node || !node.parentNode) { return null; } var parent = node.parentNode; if (this.hasCssClass(parent, theClass)) { return parent; } else { return this.getFirstParentWithClass(parent, theClass, --maxLevels); } }, isFullscreen: function (node) { if (node && documentAlias.fullScreenElement === node || documentAlias.mozFullScreenElement === node || documentAlias.webkitFullscreenElement === node || documentAlias.msFullscreenElement === node) { // msFullscreenElement is only ie11 return true; } return false; } }; function getPiwikTrackers() { if (null === customPiwikTrackers) { if ('object' === typeof Piwik && Piwik.getAsyncTrackers) { return Piwik.getAsyncTrackers(); } } if (isArray(customPiwikTrackers)) { return customPiwikTrackers; } return []; } function MediaTracker(playerName, type, resource) { this.playerName = playerName; this.type = type; this.resource = resource; this.disabled = false; this.reset(); } MediaTracker.piwikTrackers = []; MediaTracker.prototype.disable = function () { this.disabled = true; }; MediaTracker.prototype.reset = function () { this.id = utils.generateUniqueId(); this.mediaTitle = null; this.timeToInitialPlay = null; this.width = null; this.height = null; this.fullscreen = false; this.timeout = null; this.watchedTime = 0; this.lastTimeCheck = null; this.isPlaying = false; this.isPaused = false; this.mediaProgressInSeconds = 0; this.mediaLengthInSeconds = 0; this.disabled = false; this.numPlaysSameMedia = 0; this.numPlaysSameMediaOffScreen = 0; this.viewedSegments = []; this.trackedSegments = []; this.lastSentProgressRequestUrl = ''; }; MediaTracker.prototype.setResource = function (resource) { this.resource = resource; }; MediaTracker.prototype.getResource = function () { return this.resource; }; MediaTracker.prototype.makeRequestUrlFromParams = function (params) { var requestUrl = ''; for (var index in params) { if (Object.prototype.hasOwnProperty.call(params, index)) { requestUrl += index + '=' + encodeURIComponent(params[index]) + '&'; } } return requestUrl; }; MediaTracker.prototype.trackEvent = function (action) { if (this.disabled) { return; } if (!timeFirstTrackingRequest) { timeFirstTrackingRequest = utils.getCurrentTime(); } else if ((utils.getCurrentTime() - timeFirstTrackingRequest) > stopTrackingAfterXMs) { this.disable(); return; } var asyncTrackers = getPiwikTrackers(); var mediaType = 'Media' + this.type; var mediaResource = this.mediaTitle || this.resource; var requestUrl = this.makeRequestUrlFromParams({ e_c: mediaType, e_a: action, e_n: mediaResource, e_v: parseInt(Math.round(this.mediaProgressInSeconds), 10), ca: '1' }); if (asyncTrackers && asyncTrackers.length) { var i = 0, tracker; for (i; i < asyncTrackers.length; i++) { tracker = asyncTrackers[i]; if (tracker && tracker.MediaAnalytics && tracker.MediaAnalytics.isTrackEventsEnabled()) { if (rateLimit.isEventsLimitReached(tracker, mediaResource, action, this.mediaLengthInSeconds)) { logConsoleMessage('Event limit reached for event: '+action); continue; } if ('function' === typeof tracker.queueRequest && 'function' === typeof tracker.disableQueueRequest) { tracker.queueRequest(requestUrl); // we can only make use of queueRequest if disableQueueRequest exists as in other versions it was broken } else { tracker.trackRequest(requestUrl); } //after tracking request is issue we need to increment the event action per media resource rateLimit.incrLimitPerTrackerPerMediaResource(tracker, mediaResource, action); } } } else { if (typeof windowAlias._paq === 'undefined') { windowAlias._paq = []; } // we don't know if tracker supports "queueRequest" already so we have to play safe and use trackRequest windowAlias._paq.push(['trackRequest', requestUrl]); logConsoleMessage('piwikWasNotYetInitialized. This means players were scanning too early for media or there are no async trackers'); } logConsoleMessage('trackEvent', mediaType, mediaResource, action); }; MediaTracker.prototype.trackProgress = function (idView, mediaTitle, playerName, mediaType, mediaResource, watchedTimeInSeconds, progressInSeconds, mediaLength, timeToInitialPlay, width, height, fullscreen, segments) { if (this.disabled) { return; } if (!timeFirstTrackingRequest) { timeFirstTrackingRequest = utils.getCurrentTime(); } else if ((utils.getCurrentTime() - timeFirstTrackingRequest) > stopTrackingAfterXMs) { this.disable(); return; } if (this.isPlaying && !watchedTimeInSeconds) { // edge case when just starting to play a media we want to make sure if no further tracking request is sent // it counts as being played watchedTimeInSeconds = 1; } var params = { ma_id: idView, ma_ti: mediaTitle !== null ? mediaTitle : '', ma_pn: playerName, ma_mt: mediaType, ma_re: mediaResource, ma_st: parseInt(Math.floor(watchedTimeInSeconds), 10), ma_ps: parseInt(progressInSeconds, 10), ma_le: mediaLength, ma_ttp: timeToInitialPlay !== null ? timeToInitialPlay : '', ma_w: width ? width : '', ma_h: height ? height : '', ma_fs: fullscreen ? '1' : '0', ma_se: segments.join(','), ca: '1' }; var requestUrl = this.makeRequestUrlFromParams(params); if (requestUrl === this.lastSentProgressRequestUrl) { // the same request was sent previously. No need to send the same request again. // prevents overloading the server with lots of the same requests that don't need to be // processed when multiple progress events are triggered in the same second. return; } this.lastSentProgressRequestUrl = requestUrl; var asyncTrackers = getPiwikTrackers(); if (asyncTrackers && asyncTrackers.length) { var i = 0, tracker; for (i; i < asyncTrackers.length; i++) { tracker = asyncTrackers[i]; if (tracker && tracker.MediaAnalytics && tracker.MediaAnalytics.isTrackProgressEnabled()) { if ('function' === typeof tracker.queueRequest && 'function' === typeof tracker.disableQueueRequest) { tracker.queueRequest(requestUrl); } else { tracker.trackRequest(requestUrl); } } } } else { if (typeof windowAlias._paq === 'undefined') { windowAlias._paq = []; } // we don't know if tracker supports "queueRequest" already so we have to play safe and use trackRequest windowAlias._paq.push(['trackRequest', requestUrl]); logConsoleMessage('piwikWasNotYetInitialized. This means players were scanning too early for media or there are no async trackers'); } if (debugMode) { // check for debug mode is not really needed but better only to stringify when needed logConsoleMessage('trackProgress', getJson().stringify(params)); } }; MediaTracker.prototype.setFullscreen = function (isFullscreen) { if (!this.fullscreen) { this.fullscreen = !!isFullscreen; } }; MediaTracker.prototype.setWidth = function (width) { if (utils.isNumber(width)) { this.width = parseInt(width, 10); } }; MediaTracker.prototype.setHeight = function (height) { if (utils.isNumber(height)) { this.height = parseInt(height, 10); } }; MediaTracker.prototype.setMediaTitle = function (title) { this.mediaTitle = title; }; MediaTracker.prototype.getMediaTitle = function () { return this.mediaTitle; }; MediaTracker.prototype.setMediaProgressInSeconds = function (mediaProgressInSeconds) { this.mediaProgressInSeconds = mediaProgressInSeconds; if (this.isPlaying) { this.viewedSegments.push(mediaProgressInSeconds); } }; MediaTracker.prototype.getMediaProgressInSeconds = function () { return this.mediaProgressInSeconds; }; MediaTracker.prototype.setMediaTotalLengthInSeconds = function (mediaLengthInSeconds) { this.mediaLengthInSeconds = mediaLengthInSeconds; }; MediaTracker.prototype.getMediaTotalLengthInSeconds = function () { return this.mediaLengthInSeconds; }; MediaTracker.prototype.play = function () { if (this.isPlaying) { return; // already playing } this.isPlaying = true; this.setMediaProgressInSeconds(this.getMediaProgressInSeconds()); // now that we are playing we may want to add the media progress to viewed segments this.startWatchedTime(); if (isFirstPlay && this.timeToInitialPlay === null) { // we want to track time to initial play only once for the first play this.timeToInitialPlay = utils.roundTimeToSeconds(utils.getCurrentTime() - utils.getTimeScriptLoaded()); } isFirstPlay = false; if (this.isPaused) { this.isPaused = false; this.trackEvent('resume'); } else { this.trackEvent('play'); var isOffScreen = utils.isDocumentOffScreen(); this.numPlaysSameMedia++; numMediaPlaysTotal++; if (isOffScreen) { this.numPlaysSameMediaOffScreen++; numMediaPlaysTotalOffScreen++; } if (this.numPlaysSameMedia > 25 || numMediaPlaysTotal > 50) { this.disable(); } else if (this.numPlaysSameMediaOffScreen > 10 || numMediaPlaysTotalOffScreen > 15) { this.disable(); } } this.trackUpdate(); }; MediaTracker.prototype.startWatchedTime = function () { this.lastTimeCheck = utils.getCurrentTime(); }; MediaTracker.prototype.stopWatchedTime = function () { if (this.lastTimeCheck) { this.watchedTime += utils.getCurrentTime() - this.lastTimeCheck; this.lastTimeCheck = null; } }; // also when buffer start MediaTracker.prototype.seekStart = function () { if (this.isPlaying) { // if the media player is currently not playing, we can easily ignore the seek as it has no effect. Makes // sure we do not accidentally start tracking or set video to playing when the video is seeking/buffering // initally before the video has even played or when it is not playing this.stopWatchedTime(); } }; // also when buffer finish and media continues playing MediaTracker.prototype.seekFinish = function () { if (this.isPlaying) { // if the media player is currently not playing, we can easily ignore the seek as it has no effect. Makes // sure we do not accidentally start tracking or set video to playing when the video is seeking/buffering // initally before the video has even played or when it is not playing this.startWatchedTime(); } }; MediaTracker.prototype.pause = function () { if (this.isPlaying) { this.isPaused = true; this.isPlaying = false; if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } this.stopWatchedTime(); this.trackUpdate(); this.trackEvent('pause'); } }; MediaTracker.prototype.finish = function () { if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } this.stopWatchedTime(); this.trackUpdate(); this.trackEvent('finish'); // we generate a new id from now on all events will be counted towards a new "media session". // we do not call .reset as it would result in changed media title etc. but only because a media is finished // it does not mean the media actually changed so we should also not change the media title this.id = utils.generateUniqueId(); this.timeToInitialPlay = null; this.lastTimeCheck = null; this.isPlaying = false; this.isPaused = false; this.watchedTime = 0; this.mediaProgressInSeconds = 0; }; MediaTracker.prototype.trackUpdate = function () { if (this.timeout) { // we are just tracking an update below... if there was an update scheduled... cancel it... otherwise // may send eg 2 updates within few seconds clearTimeout(this.timeout); this.timeout = null; } var crtTime = utils.getCurrentTime(); if (this.lastTimeCheck) { this.watchedTime += (crtTime - this.lastTimeCheck); this.lastTimeCheck = crtTime; } var mediaLength = this.mediaLengthInSeconds; if (!mediaLength || !utils.isNumber(mediaLength)) { mediaLength = ''; } else { mediaLength = parseInt(this.mediaLengthInSeconds, 10); } var watchedTimeInSeconds = utils.roundTimeToSeconds(this.watchedTime); var progressInSeconds = this.mediaProgressInSeconds; if (progressInSeconds > mediaLength && mediaLength) { progressInSeconds = mediaLength; } var segments = []; var i,val; for (i = 0; i < this.viewedSegments.length; i++) { val = this.viewedSegments[i]; if (val >= 0 && val <= mediaLength) { if (val <= 300) { val = utils.roundUp(val, 15); } else { val = utils.roundUp(val, 30); } if (val >= 0 && val < 1) { val = 15; } if (-1 === utils.indexOfArray(segments, val) && -1 === utils.indexOfArray(this.trackedSegments, val)) { segments.push(val); // we track each segment only once to avoid some DB queries server side when the same segement has been viewed multiple times this.trackedSegments.push(val); } } } this.viewedSegments = [];// do not send the same values again this.trackProgress(this.id, this.mediaTitle, this.playerName, this.type, this.resource, watchedTimeInSeconds, progressInSeconds, mediaLength, this.timeToInitialPlay, this.width, this.height, this.fullscreen, segments); }; MediaTracker.prototype.update = function () { if (this.timeout) { return; } var watchedTimeInSeconds = utils.roundTimeToSeconds(this.watchedTime); var interval = pingIntervalInSeconds; if (!usesCustomInterval && (watchedTimeInSeconds >= 1800 || numMediaPlaysTotal > 10)) { interval = 300; } else if (!usesCustomInterval && (watchedTimeInSeconds >= 600 || numMediaPlaysTotal > 4)) { interval = 240; } else if (!usesCustomInterval && (watchedTimeInSeconds >= 300 || numMediaPlaysTotal > 2)) { interval = 120; } else if (!usesCustomInterval && watchedTimeInSeconds >= 60) { interval = 60; } interval = interval * 1000; var self = this; this.timeout = setTimeout(function () { self.trackUpdate(); self.timeout = null; }, interval); }; var rateLimit = { isEventsLimitReached: function (tracker, mediaResource, eventAction, mediaDurationInSeconds) { if (!isRateLimitEnabled) { return false; } //since we have set a rate limit per tracker, we need to check if that limit has reached or not if (rateLimit.getTotalEventsOnTracker(tracker, eventAction) >= rateLimit.getTotalAllowedEventsPerTracker(eventAction)) { logConsoleMessage('blocked due to max tracker limit reached for action: '+ eventAction); return true } //For resources with more 15minutes of play time we double the limit for pause and resume action var multiplier = (mediaDurationInSeconds && mediaDurationInSeconds > 900 && (eventAction === 'pause' || eventAction === 'resume')) ? 2 : 1; rateLimit.initializeLimitPerTrackerPerMediaResource(tracker, mediaResource, eventAction); //since we have set a rate limit per tracker per media resource, we need to check if that limit has reached or not return (tracker.MediaAnalytics.quotaEventRequests[mediaResource][eventAction] > (maxNoOfEventsAllowedPerTracker[eventAction] * multiplier)); }, getTotalEventsOnTracker: function(tracker, eventAction) { var sum = 0; if (typeof tracker.MediaAnalytics.quotaEventRequests === "undefined") { tracker.MediaAnalytics.quotaEventRequests = {}; return sum; } if (Object.keys(tracker.MediaAnalytics.quotaEventRequests).length) { for (var mediaResource in tracker.MediaAnalytics.quotaEventRequests) { sum = sum + (tracker.MediaAnalytics.quotaEventRequests[mediaResource][eventAction] || 0); } } return sum; }, getTotalAllowedEventsPerTracker: function (eventAction) { return (totalEventsAllowedPerPagePerTracker[eventAction] || defaultEventLimitForUnknownTrackingEvents); }, initializeLimitPerTrackerPerMediaResource: function(tracker, mediaResource, eventAction) { if (typeof tracker.MediaAnalytics.quotaEventRequests === "undefined") { tracker.MediaAnalytics.quotaEventRequests = {}; } // initialise total events allowed per tracker per mediaResource if not defined if (typeof tracker.MediaAnalytics.quotaEventRequests[mediaResource] === "undefined") { tracker.MediaAnalytics.quotaEventRequests[mediaResource] = initialEventsCount(); } //if the given event is not from any standard event like play, pause, resume and seek we initialise it to 0 if (typeof tracker.MediaAnalytics.quotaEventRequests[mediaResource][eventAction] === "undefined") { tracker.MediaAnalytics.quotaEventRequests[mediaResource][eventAction] = 0; } }, incrLimitPerTrackerPerMediaResource: function(tracker, mediaResource, eventAction) { if (!isRateLimitEnabled) { return; } rateLimit.initializeLimitPerTrackerPerMediaResource(tracker, mediaResource, eventAction); tracker.MediaAnalytics.quotaEventRequests[mediaResource][eventAction]++; } }; var players = { players: {}, // when registering we also will directly search for media registerPlayer: function (name, player) { if (!player || !player.scanForMedia || 'function' !== typeof player.scanForMedia) { throw new Error('A registered player does not implement the scanForMedia function'); } name = name.toLowerCase(); this.players[name] = player; }, removePlayer: function (name) { name = name.toLowerCase(); delete this.players[name]; }, getPlayer: function (name) { name = name.toLowerCase(); if (name in this.players) { return this.players[name]; } return null; }, getPlayers: function () { return this.players; }, // can be used to re-scan the dom or a particular part of the page for new medias scanForMedia: function (documentOrElement) { if (!isMediaTrackingEnabled) { return; } if ('undefined' === typeof documentOrElement || !documentOrElement) { documentOrElement = document; } var i; for (i in this.players) { if (Object.prototype.hasOwnProperty.call(this.players, i)) { this.players[i].scanForMedia(documentOrElement); } } } }; var Html5Player = function (node, type) { if (!node) { return; } if (!windowAlias.addEventListener) { // html5 audio / video is not supported in this browser return; } if (node.hasPlayerInstance) { // when scanning for media multiple times prevent from creating multiple trackers for the same video return; } node.hasPlayerInstance = true; var isVideo = mediaType.VIDEO === type; var absoluteResource = urlHelper.makeUrlAbsolute(node.currentSrc); var resource = element.getMediaResource(node, absoluteResource); var playerName = 'html5' + type.toLowerCase(); if (typeof paella === 'object' && typeof paella.opencast === 'object') { playerName = 'paella-opencast'; } else if (element.getFirstParentWithClass(node, 'video-js', 1)) { playerName = 'video.js'; } else if (element.hasCssClass(node, 'jw-video')) { playerName = 'jwplayer'; } else if (element.getFirstParentWithClass(node, 'flowplayer', 3)) { playerName = 'flowplayer'; } var tracker = new MediaTracker(playerName, type, resource); mediaTrackerInstances.push(tracker); function updateDuration() { if (node.duration) { // duration might be only available now, likely it will be going into the if below and track then the // media duration tracker.setMediaTotalLengthInSeconds(node.duration); } } function updateDimensions() { if (isVideo) { var myNode = node; // for jwplayer we need the parent div (containing the video element) to determine sizes and fullscreen if (playerName === 'jwplayer') { var parentNode = element.getFirstParentWithClass(myNode, 'jwplayer'); if (parentNode) { myNode = parentNode; } } if ('undefined' !== typeof myNode.videoWidth && myNode.videoWidth) { tracker.setWidth(myNode.videoWidth); } else if ('undefined' !== typeof myNode.clientWidth && myNode.clientWidth) { tracker.setWidth(myNode.clientWidth); } if ('undefined' !== typeof myNode.videoHeight && myNode.videoHeight) { tracker.setHeight(myNode.videoHeight); } else if ('undefined' !== typeof myNode.clientHeight && myNode.clientHeight) { tracker.setHeight(myNode.clientHeight); } tracker.setFullscreen(element.isFullscreen(myNode)); } } function updateCurrentTime() { tracker.setMediaProgressInSeconds(node.currentTime); } function updateMediaTitle() { var title = element.getMediaTitle(node); if (title) { tracker.setMediaTitle(title); } else { findCustomPlayerTitleIfNeeded(node, tracker); } } // eg jwplayer or flowplayer may provide custom resource information findCustomPlayerResource(node, tracker); updateDimensions(); updateMediaTitle(); updateDuration(); updateCurrentTime(); var isPlaying = false; var hasTrackedMediaView = false; var currentSource = null; if (node.currentSrc) { currentSource = node.currentSrc; } function findCustomPlayerTitleIfNeeded(node, tracker) { // jwplayer does not let users set an html attribute like title or data-piwik-title so we retrieve it // from the player directly if it is loaded. We can get the player Instance which is max 3 levels further up // in a div.jwplayer element if (hasJwPlayer() && !tracker.getMediaTitle()) { var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 3); if (!jwPlayerDiv) { // jwplayer 5 support jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer-video', 3); if (jwPlayerDiv && 'undefined' !== typeof jwPlayerDiv.children && jwPlayerDiv.children && jwPlayerDiv.children.length && jwPlayerDiv.children[0]) { // better be to use firstElementChild but not supported in eg IE8/9 afaik jwPlayerDiv = jwPlayerDiv.children[0]; } } if (jwPlayerDiv) { try { var player = jwplayer(jwPlayerDiv); if (player && player.getPlaylistItem) { var item = player.getPlaylistItem(); if (item && item.matomoTitle) { tracker.setMediaTitle(item.matomoTitle) } else if (item && item.piwikTitle) { tracker.setMediaTitle(item.piwikTitle) } else if (item && item.title) { tracker.setMediaTitle(item.title) } } } catch (e) { logConsoleMessage(e); } } } if (hasFlowPlayer() && !tracker.getMediaTitle()) { var flowPlayerDiv = element.getFirstParentWithClass(node, 'flowplayer', 4); if (flowPlayerDiv) { var player = flowplayer(flowPlayerDiv); if (player && player.video && player.video.matomoTitle) { tracker.setMediaTitle(player.video.matomoTitle); } else if (player && player.video && player.video.piwikTitle) { tracker.setMediaTitle(player.video.piwikTitle); } else if (player && player.video && player.video.title) { tracker.setMediaTitle(player.video.title); } else if (player && player.video && player.video.fv_title) { tracker.setMediaTitle(player.video.fv_title); } } } if (!tracker.getMediaTitle()) { var openCastTitle = documentAlias.getElementById('engage_basic_description_title'); if (openCastTitle && openCastTitle.innerText) { var title = utils.trim(openCastTitle.innerText); if (title) { tracker.setMediaTitle(title); } } else if (typeof paella === 'object' && typeof paella.opencast === 'object' && typeof paella.opencast._episode === 'object' && paella.opencast._episode.dcTitle) { var title = utils.trim(paella.opencast._episode.dcTitle); if (title) { tracker.setMediaTitle(title); } } } setDefaultFallbackTitle(node, tracker); } function findCustomPlayerResource(node, tracker) { // jwplayer does not let users set an html attribute like title or data-piwik-title so we retrieve it // from the player directly if it is loaded. We can get the player Instance which is 2 levels further up // in a div.jwplayer element if (hasJwPlayer()) { var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 3); if (!jwPlayerDiv) { // jwplayer 5 support jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer-video', 3); if (jwPlayerDiv && 'undefined' !== typeof jwPlayerDiv.children && jwPlayerDiv.children && jwPlayerDiv.children.length && jwPlayerDiv.children[0]) { // better be to use firstElementChild but not supported in eg IE8/9 afaik jwPlayerDiv = jwPlayerDiv.children[0]; } } if (jwPlayerDiv) { try { var player = jwplayer(jwPlayerDiv); if (player && player.getPlaylistItem) { // lets overwrite resource by possible playlist item. Useful when item changes after a while var item = player.getPlaylistItem(); if (item && 'undefined' !== typeof item.matomoResource && item.matomoResource) { tracker.setResource(item.matomoResource) } else if (item && 'undefined' !== typeof item.piwikResource && item.piwikResource) { tracker.setResource(item.piwikResource) } } } catch (e) { logConsoleMessage(e); } } } if (hasFlowPlayer()) { var flowPlayerDiv = element.getFirstParentWithClass(node, 'flowplayer', 4); if (flowPlayerDiv) { var player = flowplayer(flowPlayerDiv); if (player && player.video && 'undefined' !== typeof player.video.matomoResource && player.video.matomoResource) { tracker.setResource(player.video.matomoResource); } else if (player && player.video && 'undefined' !== typeof player.video.piwikResource && player.video.piwikResource) { tracker.setResource(player.video.piwikResource); } } } } function checkVideoChanged() { if (!currentSource && node.currentSrc) { currentSource = node.currentSrc; } else if (currentSource && node.currentSrc && currentSource != node.currentSrc) { currentSource = node.currentSrc; var absoluteUrl = urlHelper.makeUrlAbsolute(currentSource); var previousTitle = tracker.getMediaTitle(); // the URL has changed and we need to start tracking a new video play isPlaying = false; tracker.reset(); tracker.setResource(absoluteUrl); tracker.setMediaTitle(''); var title = element.getMediaTitle(node) if (title && title !== previousTitle) { // we make sure the title actually changed and otherwise rather set it to an empty title tracker.setMediaTitle(title); } else { findCustomPlayerTitleIfNeeded(node, tracker); } findCustomPlayerResource(node, tracker); updateDuration(); } } function trackMediaViewIfPossible() { if (!hasTrackedMediaView && (tracker.getResource() || tracker.getMediaTitle())) { hasTrackedMediaView = true; // by now we might also have updated video title information which might not be available initially updateMediaTitle(node, tracker); findCustomPlayerResource(node, tracker); tracker.trackUpdate(); // we make sure to now track it with video width and height as initially the "has media viewed request" // did not have the width/height } } function onResizeOrMetadataUpdate() { checkVideoChanged(); updateDimensions(); updateDuration(); updateCurrentTime(); trackMediaViewIfPossible(); } var seekLastTime = null; if (node.loop) { seekLastTime = 0; // we set the seek last time to zero as it would otherwise trigger a seek event for the first repeat. } var isHeaderVideo = false; if (node.loop && node.autoplay && node.muted) { // likely a header video embedded in the top of the website continuously playing... // we don't really want to track such videos very often and want to delay sending the updates isHeaderVideo = true; } node.addEventListener('playing', function() { checkVideoChanged(); if ('undefined' !== typeof node.paused && node.paused) { return; } if ('undefined' !== typeof node.ended && node.ended) { return; } if (!isPlaying) { updateCurrentTime(); isPlaying = true; tracker.play(); } }, true); node.addEventListener('durationchange', updateDuration, true); node.addEventListener('loadedmetadata', onResizeOrMetadataUpdate, true); node.addEventListener('loadeddata', onResizeOrMetadataUpdate, true); node.addEventListener('pause', function() { if (node.currentTime && node.duration && node.currentTime === node.duration) { // html5 triggers a pause event followed by a finish event when the video is over. We should not // track a pause in such a case return; } if (node.seeking) { // we are actually seeking and not pausing. Some players still trigger pause event in this case return; } updateCurrentTime(); isPlaying = false; tracker.pause(); }, true); node.addEventListener('seeking', function() { if (node.seeking) { updateCurrentTime(); var progress = parseInt(tracker.getMediaProgressInSeconds(), 10); if (seekLastTime === null || seekLastTime !== progress) { // do not trigger event for the same second twice! // also we track max 20 seek events seekLastTime = progress; tracker.trackEvent('seek'); } } }, true); node.addEventListener('ended', function() { isPlaying = false; tracker.finish(); }, true); node.addEventListener('timeupdate', function() { updateCurrentTime(); updateDuration(); if (isVideo && !tracker.width) { // sometimes html5 video player does not get a width right away updateDimensions(); } if ('undefined' !== typeof node.paused && node.paused) { return; } if ('undefined' !== typeof node.ended && node.ended) { return; } if (isHeaderVideo) { var watched = utils.roundTimeToSeconds(tracker.watchedTime); var duration = tracker.getMediaTotalLengthInSeconds(); if (watched >= 30 && duration >= 1 && duration < 30 && (watched / duration) >= 3) { // we stop tracking this after 3 repeats but only if at least played for 30 seconds... tracker.disable(); } } // we track below, so it will be counted as viewed for sure hasTrackedMediaView = true; if (!isPlaying) { // in case it is already playing when being loaded isPlaying = true; tracker.play(); } else { tracker.update(); } }, true); node.addEventListener('seeking', function() { tracker.seekStart(); }, true); node.addEventListener('seeked', function() { updateCurrentTime(); updateDuration(); tracker.seekFinish(); }, true); if (isVideo) { node.addEventListener('resize', onResizeOrMetadataUpdate, true); windowAlias.addEventListener('resize', function () { updateDimensions(); // in this case user resized only the browser, no need to check for new media title etc }, false); } // we track the view a little delayed hoping more information becomes available by then, for example a late loaded // jwplayer title, etc. // tracker.timeout so if page is unloaded before timeout, we make sure the video view will be tracked onunload tracker.timeout = setTimeout(function () { // we wait for another second and then track even if resource OR title exists, total length not needed onResizeOrMetadataUpdate(); tracker.timeout = null; }, 1500); }; Html5Player.scanForMedia = function (theDocumentOrNode) { if (!windowAlias.addEventListener) { // html5 audio / video is not supported in this browser return; } var is_open_cast = isOpenCast(); var html5VideoPlayers = theDocumentOrNode.getElementsByTagName('video'); var elementId; for (var i = 0; i < html5VideoPlayers.length; i++) { if (!element.isMediaIgnored(html5VideoPlayers[i])) { elementId = element.getAttribute(html5VideoPlayers[i], 'id'); if (is_open_cast) { var wrapper1 = theDocumentOrNode.querySelector('#videoDisplay1_wrapper'); if (wrapper1 && ('function' === typeof wrapper1.contains) && !wrapper1.contains(html5VideoPlayers[i])) { // for opencast, we only track the first video. It can eg show a presenter and the presentation // in this case we only track the presenter / main video. continue; } } if (elementId !== 'video_0' && theDocumentOrNode.querySelector('#videoPlayerWrapper_0') && theDocumentOrNode.querySelector('#video_0')) { // for opencast with paella player 6.X, we only track the first video. // It can eg show a presenter and the presentation plus many other videos. // in this case we only track the presenter / main video. continue; } new Html5Player(html5VideoPlayers[i], mediaType.VIDEO); } } html5VideoPlayers = null; var html5AudioPlayers = theDocumentOrNode.getElementsByTagName('audio'); for (var i = 0; i < html5AudioPlayers.length; i++) { if (!element.isMediaIgnored(html5AudioPlayers[i])) { new Html5Player(html5AudioPlayers[i], mediaType.AUDIO); } } html5AudioPlayers = null; if ('undefined' !== typeof soundManager && soundManager && 'undefined' !== typeof soundManager.sounds) { for (var i in soundManager.sounds) { if (Object.prototype.hasOwnProperty.call(soundManager.sounds, i)) { var sound = soundManager.sounds[i]; if (sound && sound.isHTML5 && sound._a) { if (!element.isMediaIgnored(sound._a)) { new Html5Player(sound._a, mediaType.AUDIO); } } } } } }; var JwPlayerInt = function (node, type) { if (!node || !windowAlias.addEventListener) { // html5 audio / video is not supported in this browser return; } if (node.hasPlayerInstance || !hasJwPlayer()) { // when scanning for media multiple times prevent from creating multiple trackers for the same video return; } var jwPlayerDiv = element.getFirstParentWithClass(node, 'jwplayer', 3); if (!jwPlayerDiv) { return; } var player = jwplayer(jwPlayerDiv); if (!player || !player.getItem || 'undefined' === (typeof player.getItem())) { return; } node.hasPlayerInstance = true; function getResource(player) { var item = player.getPlaylistItem(); if (item && item.matomoResource) { return item.matomoResource; } if (item && item.piwikResource) { return item.piwikResource; } if (item && item.file) { return item.file; } return ''; } function getMediaTitle(player) { var item = player.getPlaylistItem(); if (item && item.matomoTitle) { return item.matomoTitle; } if (item && item.piwikTitle) { return item.piwikTitle; } if (item && item.title) { return item.title; } if ('function' === typeof mediaTitleFallback) { var fallbackTitle = mediaTitleFallback(node); if (fallbackTitle) { return fallbackTitle; } } return null; } function maybeResetPlayer(player, tracker, currentSource) { var resource = getResource(player); if (currentSource && resource && currentSource != resource) { currentSource = resource; // the URL has changed and we need to start tracking a new video play tracker.reset(); tracker.setResource(urlHelper.makeUrlAbsolute(currentSource)); tracker.setMediaTitle(getMediaTitle(player)); tracker.setWidth(player.getWidth()); tracker.setHeight(player.getHeight()); tracker.setFullscreen(player.getFullscreen()); return true; } return false; } var playerResource = getResource(player); var absoluteResource = urlHelper.makeUrlAbsolute(playerResource); var resource = element.getMediaResource(node, absoluteResource); var tracker = new MediaTracker('jwplayer', type, resource); tracker.setMediaTitle(getMediaTitle(player)); tracker.setWidth(player.getWidth()); tracker.setHeight(player.getHeight()); tracker.setFullscreen(player.getFullscreen()); mediaTrackerInstances.push(tracker); var duration = player.getDuration(); if (duration) { tracker.setMediaTotalLengthInSeconds(duration); } var isPlaying = false, currentSource = playerResource; var seekLastTime = null; player.on('play', function() { maybeResetPlayer(player, tracker, currentSource); isPlaying = true; tracker.play(); }, true); player.on('playlistItem', function() { maybeResetPlayer(player, tracker, currentSource); if (player.getState() !== 'playing') { isPlaying = false; } }, true); player.on('pause', function() { if (player.getPosition() && player.getDuration() && player.getPosition() === player.getDuration()) { // it may trigger a pause event followed by a finish event when the video is over. We should not // track a pause in such a case return; } tracker.pause(); }, true); player.on('complete', function() { tracker.finish(); }, true); player.on('time', function() { var position = player.getPosition(); if (position) { tracker.setMediaProgressInSeconds(position); } var duration = player.getDuration(); if (duration) { tracker.setMediaTotalLengthInSeconds(duration); } if (isPlaying) { tracker.update(); } else { // in case it is already playing when being loaded isPlaying = true; tracker.play(); } }, true); player.on('seek', function() { tracker.seekStart(); }, true); player.on('seeked', function() { var position = player.getPosition(); if (position) { tracker.setMediaProgressInSeconds(position); } var duration = player.getDuration(); if (duration) { tracker.setMediaTotalLengthInSeconds(duration); } tracker.seekFinish(); var progress = parseInt(tracker.getMediaProgressInSeconds(), 10); if (seekLastTime === null || seekLastTime !== progress) { // do not trigger event for the same second twice! // also we track max 20 seek events seekLastTime = progress; tracker.trackEvent('seek'); } }, true); player.on('resize', function() { tracker.setWidth(player.getWidth()); tracker.setHeight(player.getHeight()); tracker.setFullscreen(player.getFullscreen()); }, true); player.on('fullscreen', function () { tracker.setWidth(player.getWidth()); tracker.setHeight(player.getHeight()); tracker.setFullscreen(player.getFullscreen()); }, false); tracker.trackUpdate(); }; JwPlayerInt.scanForMedia = function (theDocumentOrNode) { if (!windowAlias.addEventListener || !hasJwPlayer()) { // this browser is not supported return; } var objects = theDocumentOrNode.getElementsByTagName('object'); for (var i = 0; i < objects.length; i++) { if (!element.isMediaIgnored(objects[i]) && element.hasCssClass(objects[i], 'jw-swf')) { new JwPlayerInt(objects[i], mediaType.VIDEO); } } objects = null; }; var VimeoPlayer = function (node, type) { // detect universally embedded videos if (!node) { return; } if (!windowAlias.addEventListener) { // html5 audio / video is not supported in this browser return; } if (node.playerInstance) { // when scanning for media multiple times. Prevent creating multiple trackers for the same video return; } node.playerInstance = true; var src = element.getAttribute(node, 'src'); var resourceToTrack = element.getMediaResource(node, null) var tracker = new MediaTracker('vimeo', type, resourceToTrack); tracker.setWidth(node.clientWidth); tracker.setHeight(node.clientHeight); tracker.setFullscreen(element.isFullscreen(node)); mediaTrackerInstances.push(tracker); windowAlias.addEventListener('resize', function () { tracker.setWidth(node.clientWidth); tracker.setHeight(node.clientHeight); tracker.setFullscreen(element.isFullscreen(node)); }, false); var title = element.getMediaTitle(node); // we may overwrite the title if no data-piwik-title is set. We can get from the vimeo API a much better // name than from the attributes title or alt var canOverwriteTitle = !element.getAttribute(node, 'data-piwik-title') && !element.getAttribute(node, 'data-matomo-title'); if (title) { tracker.setMediaTitle(title); } node.matomoSeekLastTime = null; var onMessageReceived = function (event) { if (!(/^(https?:)?\/\/(player.)?vimeo.com(?=$|\/)/).test(event.origin)) { return false; } if (!event || !event.data) { return; } if (node.contentWindow && event.source && node.contentWindow !== event.source) { return; } var data = event.data; if ('string' === typeof data) { data = getJson().parse(event.data); } if (('event' in data && data.event === 'ready') || ('method' in data && data.method === 'ping')) { if (playerOrigin === '*') { playerOrigin = event.origin; } if (!node.isVimeoReady) { node.isVimeoReady = true; postAction('addEventListener', 'play'); postAction('addEventListener', 'pause'); postAction('addEventListener', 'finish'); postAction('addEventListener', 'seek'); postAction('addEventListener', 'seeked'); postAction('addEventListener', 'playProgress'); postAction('getVideoTitle'); } return; } if ('method' in data) { logConsoleMessage('vimeoMethod', data.method); switch (data.method) { case 'getVideoTitle': if (data.value && canOverwriteTitle) { tracker.setMediaTitle(data.value); } else if (canOverwriteTitle) { setDefaultFallbackTitle(node, tracker); } canOverwriteTitle = true; // in case video changes we need to update the title after the initial first video tracker.trackUpdate(); break; case 'getPaused': if (data.value) { tracker.pause(); } } return; } if ('event' in data) { var eventName = data.event; logConsoleMessage('vimeoEvent', eventName); if (data && data.data) { data = data.data; } if (tracker && data && data.seconds) { if (tracker.getMediaProgressInSeconds() === data.seconds && (eventName === 'playProgress' || eventName === 'timeupdate')) { // vimeo does eg send a playProgress event every 2 hours, even when it is inactive. To prevent // this bug we do not track anything unless it is updated. // this way we also make it a little faster as we do not have to track an update for the // very same second 4 or 5 times per second. return; } tracker.setMediaProgressInSeconds(data.seconds); } if (tracker && data && data.duration) { tracker.setMediaTotalLengthInSeconds(data.duration); } switch (eventName) { case 'play': tracker.play(); break; case 'timeupdate': case 'playProgress': if (tracker._isSeeking) { tracker._isSeeking = false; tracker.seekFinish(); } tracker.update(); break; case 'seek': tracker.seekStart(); tracker._isSeeking = true; break; case 'seeked': var progress = parseInt(tracker.getMediaProgressInSeconds(), 10); if (node.matomoSeekLastTime === null || node.matomoSeekLastTime !== progress) { // do not trigger event for the same second twice! // also we track max 20 seek events node.matomoSeekLastTime = progress; tracker.trackEvent('seek'); } break; case 'pause': if (data && data.seconds && data && data.duration && data.seconds === data.duration) { // vimeo triggers a pause event followed by a finish event when the video is over. We should not // track a pause in such a case logConsoleMessage('ignoring pause event because video is finished'); break; } setTimeout(function () { // we only track a pause event, if it is still paused in like a second. otherwise it is likely a seek postAction('getPaused'); }, 700); break; case 'finish': tracker.finish(); break; } } } windowAlias.addEventListener('message', onMessageReceived, true); var playerOrigin = '*'; tracker._isSeeking = false; function postAction(method, value) { var data = {method: method}; if (value !== undefined) { data.value = value; } if (node && node.contentWindow) { if (navigator && navigator.userAgent) { var ieVersion = parseFloat(navigator.userAgent.toLowerCase().replace(/^.*msie (\d+).*$/, '$1')); if (ieVersion >= 8 && ieVersion < 10) { data = getJson().stringify(data); } } node.contentWindow.postMessage(data, playerOrigin); } } postAction('ping'); }; VimeoPlayer.scanForMedia = function (theDocumentOrNode) { if (!windowAlias.addEventListener) { // vimeo iframe api is not supported in this browser return; } var videos = theDocumentOrNode.getElementsByTagName('iframe'); for (var i = 0; i < videos.length; i++) { if (element.isMediaIgnored(videos[i])) { continue; } var src = element.getAttribute(videos[i], 'src'); if (src && (src.indexOf('player.vimeo.com') > 0 || (src.indexOf('vimeo.com') > 0 && src.indexOf('embed') > 0)) ) { new VimeoPlayer(videos[i], mediaType.VIDEO); } } videos = null; }; var YoutubePlayer = function (node, type) { if (!node) { return; } if (!windowAlias.addEventListener) { // youtube does not support this browser return; } if (node.playerInstance) { // when scanning for media multiple times prevent from creating multiple trackers for the same video return; } if (typeof Plyr === 'function' && element.getFirstParentWithClass(node, 'plyr', 2)) { // not compatible with plyr because both try to do new YT.Player and this prevents progress bar being updated return; } var resourceToTrack = element.getMediaResource(node, null); var tracker = new MediaTracker('youtube', type, resourceToTrack); tracker.setWidth(node.clientWidth); tracker.setHeight(node.clientHeight); tracker.setFullscreen(element.isFullscreen(node)); mediaTrackerInstances.push(tracker); windowAlias.addEventListener('resize', function () { tracker.setWidth(node.clientWidth); tracker.setHeight(node.clientHeight); tracker.setFullscreen(element.isFullscreen(node)); }, false); var title = element.getMediaTitle(node); if (title) { tracker.setMediaTitle(title); } var isSeeking = false; var updateInterval = null; // we may overwrite the title if no data-piwik-title is set. We can get from the YT API a much better // name than from the attributes title or alt var canOverwriteTitle = !element.getAttribute(node, 'data-piwik-title') && !element.getAttribute(node, 'data-matomo-title'); var hasPlayingInitialized = false; var isPaused = false; var currentVideoId = null; function onStateChange(event) { if (!event || !event.target) { return; } var target = event.target; var playerState; if (event && 'undefined' !== typeof event.data && null !== event.data) { playerState = event.data; } else { if (!target.getPlayerState) { logConsoleMessage('youtubeMissingPlayerState'); return; } playerState = target.getPlayerState(); } logConsoleMessage('youtubeStateChange', playerState); switch (playerState) { case YT.PlayerState.ENDED: if (target.getCurrentTime) { tracker.setMediaProgressInSeconds(target.getCurrentTime()); } if (target.getDuration) { tracker.setMediaTotalLengthInSeconds(target.getDuration()); } tracker.finish(); if (updateInterval) { clearInterval(updateInterval); updateInterval = null; } break; case YT.PlayerState.PLAYING: // playing var videoData = null; if (target.getVideoData) { videoData = target.getVideoData(); } if (!currentVideoId && videoData && videoData.video_id) { currentVideoId = videoData.video_id; } else if (currentVideoId && videoData && videoData.video_id && currentVideoId != videoData.video_id) { currentVideoId = videoData.video_id; // the URL has changed and we need to start playing another video (playlist) tracker.reset(); if (target.getVideoUrl) { tracker.setResource(target.getVideoUrl()); } canOverwriteTitle = true; hasPlayingInitialized = false; isSeeking = false; logConsoleMessage('currentVideoId has changed to ' + currentVideoId); } if (target.getCurrentTime) { tracker.setMediaProgressInSeconds(target.getCurrentTime()); } if (target.getDuration) { tracker.setMediaTotalLengthInSeconds(target.getDuration()); } if (canOverwriteTitle) { if (videoData && videoData.title) { tracker.setMediaTitle(videoData.title); } canOverwriteTitle = false; // no need from now on to set it again } if (!hasPlayingInitialized || isPaused) { hasPlayingInitialized = true; isPaused = false; isSeeking = false; tracker.play(); } else if (isSeeking) { isSeeking = false; tracker.seekFinish(); } tracker.update(); if (!updateInterval) { var lastTimeIntervals = []; updateInterval = setInterval(function () { if (tracker.isPlaying) { if (target && target.getCurrentTime) { var currentTime = target.getCurrentTime(); tracker.setMediaProgressInSeconds(currentTime); lastTimeIntervals.push(currentTime); if (lastTimeIntervals.length > 60) { // if over 60 seconds no progress has been made, we assume the video paused // refs DEV-1962 where the video stats keeps being in state playing when a // youtube video is put in the background invisible but still in the DOM // we then need to detect this case somehow lastTimeIntervals.shift(); var z = 0; var allEntriesAreSame = true; for (z = 0; z < lastTimeIntervals.length; z++) { if (lastTimeIntervals[z] !== lastTimeIntervals[0]) { allEntriesAreSame = false; } } if (allEntriesAreSame) { isPaused = true; tracker.pause(); lastTimeIntervals = []; return; } } } tracker.update(); } }, 1 * 1000); // try to send ping every second } break; case -1: case YT.PlayerState.PAUSED: setTimeout(function() { // we need to track pauses with a second delay to differentiate seeks from pauses if (target && target.getPlayerState && target.getPlayerState() == YT.PlayerState.PAUSED) { if (target && target.getCurrentTime) { tracker.setMediaProgressInSeconds(target.getCurrentTime()); } // if still paused after one second, we assume it was actually paused and not soomed tracker.pause(); isPaused = true; if (updateInterval) { clearInterval(updateInterval); updateInterval = null; } } else { logConsoleMessage('target not found in YT paused state'); } }, 1000); break; case YT.PlayerState.BUFFERING: tracker.seekStart(); isSeeking = true; if (updateInterval) { clearInterval(updateInterval); updateInterval = null; } break; } } node.playerInstance = new YT.Player(node, { events: { 'onReady': function (event) { if (!event || !event.target) { return; } if (canOverwriteTitle && event.target && event.target.getVideoData) { var videoData = event.target.getVideoData(); if (videoData && videoData.title) { tracker.setMediaTitle(videoData.title); } else { setDefaultFallbackTitle(node, tracker); } } tracker.trackUpdate(); if (event.target.getPlayerState && event.target.getPlayerState() == YT.PlayerState.PLAYING) { // when video is already playing when the tracker starts onStateChange(event); } }, 'onError': function (event) { if (!event || !event.data) { return; } if (tracker.isPlaying) { isPaused = true; tracker.pause(); } logConsoleMessage('YT onError event happened'); }, 'onStateChange': onStateChange } }); } YoutubePlayer.scanForMedia = function (theDocumentOrNode) { if (!windowAlias.addEventListener) { // youtube is not supported in this browser return; } var youtubeVideos = []; var iframePlayers = theDocumentOrNode.getElementsByTagName('iframe'); for (var i = 0; i < iframePlayers.length; i++) { if (element.isMediaIgnored(iframePlayers[i])) { continue; } var src = element.getAttribute(iframePlayers[i], 'src'); if (src && (src.indexOf('youtube.com') > 0 || src.indexOf('youtube-nocookie.com') > 0)) { element.setAttribute(iframePlayers[i], 'enablejsapi', 'true'); youtubeVideos.push(iframePlayers[i]); } } iframePlayers = null; function replaceMethod(methodNameToReplace, theFunction) { if (!(methodNameToReplace in window)) { return; } var oldMethodBackup = window[methodNameToReplace]; if ('function' !== typeof oldMethodBackup) { return; } try { if (oldMethodBackup.toString && oldMethodBackup.toString().indexOf('function replaceMe') === 0) { // the method is already replaced, to not replace it again and again and again return; } } catch (e) {} function replaceMe() { try { oldMethodBackup.apply(window, [].slice.call(arguments, 0)); theFunction(); } catch (error) { // in case the users method has an error we ignore it. theFunction(); throw error; } }; window[methodNameToReplace] = replaceMe; } function isYoutubeLoaded() { return 'object' === typeof YT && YT && YT.Player; } function onYoutubeReady() { if (!isYoutubeLoaded()) { return; } var iframePlayers = theDocumentOrNode.getElementsByTagName('iframe'); for (var i = 0; i < iframePlayers.length; i++) { if (element.isMediaIgnored(iframePlayers[i])) { continue; } var src = element.getAttribute(iframePlayers[i], 'src'); if (src && (src.indexOf('youtube.com') > 0 || src.indexOf('youtube-nocookie.com') > 0)) { if (iframePlayers[i].setAttribute) { iframePlayers[i].setAttribute('enablejsapi', 'true'); } new YoutubePlayer(iframePlayers[i], mediaType.VIDEO); } } } if (youtubeVideos && youtubeVideos.length) { if (isYoutubeLoaded()) { onYoutubeReady(); } else { if (windowAlias.onYouTubeIframeAPIReady) { // we need to replace each time this method is called if not loaded yet as eg a user could have // overwritten our onYouTubeIframeAPIReady with their custom callback eg between "onReady" and "onLoad" replaceMethod('onYouTubeIframeAPIReady', onYoutubeReady); loadIframeAPIScript(false) } else if (windowAlias.onYouTubePlayerAPIReady) { // we need to replace each time this method is called if not loaded yet as eg a user could have // overwritten our onYouTubePlayerAPIReady with their custom callback eg between "onReady" and "onLoad" replaceMethod('onYouTubePlayerAPIReady', onYoutubeReady); loadIframeAPIScript(false) } else { windowAlias.onYouTubeIframeAPIReady = onYoutubeReady; loadIframeAPIScript(true); } } } function loadIframeAPIScript(skipYTCheck) { //load the iframe api script if it doesn't exists or skipYTCheck is set to true if (!skipYTCheck && (typeof windowAlias.YT === 'object' || documentAlias.querySelectorAll('script[src="https://www.youtube.com/iframe_api"]').length > 0)) { return; } var tag = documentAlias.createElement('script'); tag.src = "https://www.youtube.com/iframe_api"; var scripts = documentAlias.getElementsByTagName('script'); if (scripts && scripts.length) { var scriptTag = scripts[0]; scriptTag.parentNode.insertBefore(tag, scriptTag); } else if (documentAlias.body) { documentAlias.body.appendChild(tag); } } youtubeVideos = null; }; var SoundcloudPlayer = function (node, type) { if (!node) { return; } if (node.playerInstance) { // when scanning for media multiple times prevent from creating multiple trackers for the same video return; } var player = new SC.Widget(node); node.playerInstance = player; // we do not read "src" attribute of iframe because it contains an unusable url... therefore we don't use // element.getMediaResource var resourceToTrack = element.getAttribute(node, 'data-matomo-resource'); if (!resourceToTrack) { resourceToTrack = element.getAttribute(node, 'data-piwik-resource'); } var tracker = new MediaTracker('soundcloud', type, resourceToTrack); mediaTrackerInstances.push(tracker); var title = element.getMediaTitle(node); if (title) { tracker.setMediaTitle(title); } var isSeeking = false; var updateInterval = null; // we may overwrite the title if no data-piwik-title is set. We can get from the SC API a much better // name than from the attributes title or alt var canOverwriteTitle = !element.getAttribute(node, 'data-piwik-title') && !element.getAttribute(node, 'data-matomo-title'); function canTrack() { // we only start once we have async retrieved all information return tracker.getMediaTitle() && tracker.getResource(); } var currentId = null; function getCurrentSound(callback) { player.getCurrentSound(function (event) { if (event === null) { // looks like a playlist player.getCurrentSoundIndex(function (index){ if (index >= 0) { player.getSounds(function (sounds) { if (index in sounds && sounds[index]) { callback(sounds[index]); } }); } }); } else { callback(event); } }); } function onReceiveMetadata(event) { if (!event) { return; } currentId = event.id; if (canOverwriteTitle && !tracker.getMediaTitle() && event.title) { tracker.setMediaTitle(event.title); } if (event.uri && !tracker.getResource()) { tracker.setResource(event.uri) } if (event.duration) { tracker.setMediaTotalLengthInSeconds(parseInt(Math.floor(event.duration / 1000))); } tracker.trackUpdate(); } function checkAudioChanged(event) { // for performance reasons we only want to check if sound changed when duration changes if (event && event.soundId && currentId !== event.soundId) { currentId = event.soundId; tracker.reset(); tracker.setResource(''); tracker.setMediaTitle(''); // stops any further tracking until metadata loaded canOverwriteTitle = true; isSeeking = false; getCurrentSound(onReceiveMetadata); logConsoleMessage('currentId has changed to ' + currentId); return true; } return false; } function updateDuration() { player.getDuration(function (value) { tracker.setMediaTotalLengthInSeconds(parseInt(Math.floor(value / 1000))); }); } function updateCurrentTime(event) { if ('object' === typeof event && 'undefined' !== typeof event.currentPosition) { tracker.setMediaProgressInSeconds(parseInt(Math.floor(event.currentPosition / 1000))); } } var blockPlayProgressReConcurrency = false; player.bind(SC.Widget.Events.READY, function (event) { getCurrentSound(onReceiveMetadata); player.bind(SC.Widget.Events.PLAY, function (event) { if (!canTrack()) { return; } if (checkAudioChanged(event)) { return; } updateDuration(); updateCurrentTime(event); tracker.play(); }); player.bind(SC.Widget.Events.PLAY_PROGRESS, function (event) { if (!canTrack()) { return; } if (checkAudioChanged(event)) { return; } updateDuration(); updateCurrentTime(event); if (blockPlayProgressReConcurrency) { return; } if (tracker.isPaused) { tracker.play(); return; } if (!tracker.isPlaying) { return; } if (isSeeking) { isSeeking = false; tracker.seekFinish(); } tracker.update(); }); player.bind(SC.Widget.Events.PAUSE, function (event) { if (!canTrack()) { return; } if (checkAudioChanged(event)) { return; } updateDuration(); updateCurrentTime(event); if (tracker.getMediaProgressInSeconds() && tracker.getMediaTotalLengthInSeconds() === tracker.getMediaProgressInSeconds()) { // soundcloud triggers a pause event followed by a finish event when the video is over. We should not // track a pause in such a case logConsoleMessage('ignoring pause event because video is finished'); return; } tracker.pause(); blockPlayProgressReConcurrency = true; setTimeout(function () { // we block any update for a second otherwise it triggers pause plus directly resume re concurrency blockPlayProgressReConcurrency = false; }, 1000); }); player.bind(SC.Widget.Events.FINISH, function (event) { if (!canTrack()) { return; } if (checkAudioChanged(event)) { return; } updateDuration(); updateCurrentTime(event); tracker.finish(); }); player.bind(SC.Widget.Events.SEEK, function (event) { if (!canTrack()) { return; } if (checkAudioChanged(event)) { return; } updateDuration(); updateCurrentTime(event); tracker.seekStart(); isSeeking = true; }); }); } SoundcloudPlayer.scanForMedia = function (theDocumentOrNode) { function findAudios() { var audios = []; var iframePlayers = theDocumentOrNode.getElementsByTagName('iframe'); for (var i = 0; i < iframePlayers.length; i++) { if (element.isMediaIgnored(iframePlayers[i])) { continue; } var src = element.getAttribute(iframePlayers[i], 'src'); if (src && src.indexOf('w.soundcloud.com') > 0) { audios.push(iframePlayers[i]); } } return audios; } function isSoundcloudLoaded() { return 'object' === typeof SC && SC && SC.Widget; } function onSoundcloudReady() { if (!isSoundcloudLoaded()) { return; } var audios = findAudios(); for (var i = 0; i < audios.length; i++) { var src = element.getAttribute(audios[i], 'src'); if (src && src.indexOf('w.soundcloud.com') > 0) { new SoundcloudPlayer(audios[i], mediaType.AUDIO); } } } var soundcloudAudios = findAudios(); if (soundcloudAudios && soundcloudAudios.length) { if (isSoundcloudLoaded()) { onSoundcloudReady(); } else { var tag = documentAlias.createElement('script'); tag.src = "https://w.soundcloud.com/player/api.js"; tag.onload = onSoundcloudReady; var scripts = documentAlias.getElementsByTagName('script'); if (scripts && scripts.length) { var scriptTag = scripts[0]; scriptTag.parentNode.insertBefore(tag, scriptTag); } else if (documentAlias.body) { documentAlias.body.appendChild(tag); } } } soundcloudAudios = null; }; players.registerPlayer('html5', Html5Player); players.registerPlayer('vimeo', VimeoPlayer); players.registerPlayer('youtube', YoutubePlayer); players.registerPlayer('jwplayer', JwPlayerInt); players.registerPlayer('soundcloud', SoundcloudPlayer); function enrichTracker(tracker) { if ('undefined' !== typeof tracker.MediaAnalytics) { return; } tracker.MediaAnalytics = { enableEvents: true, enableProgress: true, quotaEventRequests: {}, disableTrackEvents: function () { this.enableEvents = false; }, enableTrackEvents: function () { this.enableEvents = true; }, isTrackEventsEnabled: function () { return isMediaTrackingEnabled && this.enableEvents; }, disableTrackProgress: function () { this.enableProgress = false; }, enableTrackProgress: function () { this.enableProgress = true; }, isTrackProgressEnabled: function () { return isMediaTrackingEnabled && this.enableProgress; } }; /*!! mediaTrackerReadyHook */ Piwik.trigger('MediaAnalytics.TrackerInitialized', [tracker]); } function callAsyncReadyMethod() { if (typeof window === 'object' && 'function' === typeof windowAlias.piwikMediaAnalyticsAsyncInit) { windowAlias.piwikMediaAnalyticsAsyncInit(); } if (typeof window === 'object' && 'function' === typeof windowAlias.matomoMediaAnalyticsAsyncInit) { windowAlias.matomoMediaAnalyticsAsyncInit(); } isPluginInitialized = true; } var jwPlayerFound = false; var flowPlayerFound = false; function setUpPlayerReadyEvents() { if (!jwPlayerFound && hasJwPlayer()) { jwPlayerFound = true; // jw player might be ready only later and eg video element might be there only later. Works only if jwPlayer is loaded // before piwik var jwPlayerInstance = jwplayer(); if ('object' === typeof jwPlayerInstance && 'function' === typeof jwPlayerInstance.on) { jwPlayerInstance.on('ready', function (event) { players.scanForMedia(document); }); } } if (!flowPlayerFound && hasFlowPlayer()) { flowPlayerFound = true; // flowplayer might be ready only later and eg video element might be there only later. Works only if flowplayer is loaded // before flowplayer flowplayer(function (api, root) { if (api) { api.on('ready', function () { players.scanForMedia(document); }); api.on('load', function () { players.scanForMedia(document); }); } }); var flowplayerApi = flowplayer(); if ('object' === typeof flowplayerApi && 'function' === typeof flowplayerApi.on) { flowplayerApi.on('ready', function () { players.scanForMedia(document); }); flowplayerApi.on('load', function () { players.scanForMedia(document); }); } } } function startScanningForMedia() { // we test for tracker instance only in onReady and onLoad in case tracker instances were created between // init Matomo and the onReady or onLoad event Piwik.DOM.onReady(function () { var trackers = getPiwikTrackers(); if (!trackers || !isArray(trackers) || !trackers.length) { // no single tracker has been created yet. We do not automatically scan for media as a user might only // later create a tracker return; } players.scanForMedia(document); setUpPlayerReadyEvents(); }); Piwik.DOM.onLoad(function () { var trackers = getPiwikTrackers(); if (!trackers || !isArray(trackers) || !trackers.length) { // no single tracker has been created yet. We do not automatically scan for media as a user might only // later create a tracker return; } players.scanForMedia(document); setUpPlayerReadyEvents(); }); } function init() { if ('object' === typeof windowAlias && 'object' === typeof windowAlias.Piwik && 'object' === typeof windowAlias.Piwik.MediaAnalytics) { // do not initialize media analytics twice return; } if ('object' === typeof windowAlias && !windowAlias.Piwik) { // piwik is not defined yet return; } Piwik.MediaAnalytics = { utils: utils, url: urlHelper, element: element, players: players, rateLimit: rateLimit, MediaTracker: MediaTracker, mediaType: mediaType, scanForMedia: function (node) { players.scanForMedia(node || document); }, setPingInterval: function (globalMediaPingIntervalInSeconds) { if (10 > globalMediaPingIntervalInSeconds) { throw new Error('Ping interval needs to be at least ten seconds'); } usesCustomInterval = true; pingIntervalInSeconds = parseInt(globalMediaPingIntervalInSeconds, 10); }, removePlayer: function (playerName) { players.removePlayer(playerName); }, addPlayer: function (playerName, player) { players.registerPlayer(playerName, player); }, disableMediaAnalytics: function () { isMediaTrackingEnabled = false; }, enableMediaAnalytics: function () { isMediaTrackingEnabled = true; }, setMatomoTrackers: function (trackers) { this.setPiwikTrackers(trackers); }, setPiwikTrackers: function (trackers) { if (trackers === null) { customPiwikTrackers = null; return; } if (!isArray(trackers)) { trackers = [trackers]; } customPiwikTrackers = trackers; if (isPluginInitialized) { startScanningForMedia(); } }, setMediaTitleFallback: function (fallbackCallback) { if ('function' !== typeof fallbackCallback) { throw new Error('The mediaTitleFallback needs to be callback function'); } mediaTitleFallback = fallbackCallback; }, getMatomoTrackers: function () { return getPiwikTrackers(); }, getPiwikTrackers: function () { return getPiwikTrackers(); }, isMediaAnalyticsEnabled: function () { return isMediaTrackingEnabled; }, setMaxTrackingTime: function (stopAfterSeconds) { stopTrackingAfterXMs = parseInt(stopAfterSeconds, 10) * 1000; }, enableDebugMode: function () { debugMode = true }, enableRateLimit: function() { isRateLimitEnabled = true; }, disableRateLimit: function() { isRateLimitEnabled = false; } }; Piwik.addPlugin('MediaAnalytics', { unload: function () { var tracker; logConsoleMessage('tracker intances mediaTrackerInstances'); for (var i = 0; i < mediaTrackerInstances.length; i++) { tracker = mediaTrackerInstances[i]; if (tracker && tracker.timeout) { logConsoleMessage('before unload'); tracker.trackUpdate(); } } }, log: function (eventParams) { var asyncTrackers = getPiwikTrackers(); if (asyncTrackers && asyncTrackers.length) { for (var i = 0; i < asyncTrackers.length; i++) { if (typeof asyncTrackers[i].MediaAnalytics.quotaEventRequests !== "undefined" && Object.keys(asyncTrackers[i].MediaAnalytics.quotaEventRequests).length > 0) { asyncTrackers[i].MediaAnalytics.quotaEventRequests = {}; } } } return ''; } }); if (windowAlias.Piwik.initialized) { // tracker was separately loaded via separate include. we need to enrich already created trackers var asyncTrackers = Piwik.getAsyncTrackers(); var i = 0; for (i; i < asyncTrackers.length; i++) { enrichTracker(asyncTrackers[i]); } Piwik.on('TrackerSetup', enrichTracker); // now that the methods are set on the tracker instance we check if there were calls that couldn't be executed // the first time because the media analytics plugin was not loaded yet (but it is now) Piwik.retryMissedPluginCalls(); callAsyncReadyMethod(); startScanningForMedia(); Piwik.on('TrackerAdded', startScanningForMedia); } else { Piwik.on('TrackerSetup', enrichTracker); Piwik.on('MatomoInitialized', function () { callAsyncReadyMethod(); // at this point the first tracker was created, and all methods called by a user on _paq applied. // this means now we can start looking for media because if someone has disabled eg tracking events // or tracking progress or enabled debug etc we can be sure the media tracker has been configured startScanningForMedia(); Piwik.on('TrackerAdded', startScanningForMedia); }); } } if ('object' === typeof windowAlias.Piwik) { init(); } else { // tracker is loaded separately for sure if ('object' !== typeof windowAlias.matomoPluginAsyncInit) { windowAlias.matomoPluginAsyncInit = []; } windowAlias.matomoPluginAsyncInit.push(init); } })();