plugins/browser-plugin-youtube-tracking/src/initialization.ts
import { type BrowserPlugin } from '@snowplow/browser-tracker-core';
import {
MediaEventType,
SnowplowMediaPlugin,
endMediaTracking,
startMediaTracking,
trackMediaBufferEnd,
trackMediaBufferStart,
trackMediaEnd,
trackMediaError,
trackMediaPause,
trackMediaPlay,
trackMediaPlaybackRateChange,
trackMediaQualityChange,
trackMediaReady,
trackMediaSeekEnd,
trackMediaSeekStart,
trackMediaSelfDescribingEvent,
trackMediaVolumeChange,
updateMediaTracking,
} from '@snowplow/browser-plugin-media';
import type { Logger } from '@snowplow/tracker-core';
import { v4 as uuid } from 'uuid';
import { buildPlayerEntity, buildYouTubeEntity } from './entities';
import { addUrlParam, parseUrlParams } from './helperFunctions';
import { trackingOptionsParser } from './options';
import type {
LegacyYouTubeMediaTrackingConfiguration,
TrackedPlayer,
TrackingOptions,
YouTubeMediaTrackingConfiguration,
} from './types';
const YouTubeIFrameAPIURL = 'https://www.youtube.com/iframe_api';
const trackedPlayers: Record<string, TrackedPlayer> = {};
const trackingQueue: Array<TrackingOptions> = [];
const legacyApiSessions: string[] = [];
let LOG: Logger | undefined = undefined;
/**
* Create a YouTubeTrackingPlugin instance.
* @returns The created YouTubeTrackingPlugin instance
*/
export function YouTubeTrackingPlugin(): BrowserPlugin {
return {
...SnowplowMediaPlugin(),
logger: (logger: Logger) => {
LOG = logger;
},
};
}
/**
* Start media tracking for the given player and configuration.
* @param args Tracking configuration
* @returns The media session ID for the player
* @since 4.0.0
*/
export function startYouTubeTracking(args: YouTubeMediaTrackingConfiguration): string | void {
try {
const conf = trackingOptionsParser(args);
addPlayer(conf);
startMediaTracking({
...conf.config,
id: conf.sessionId,
captureEvents: conf.captureEvents,
boundaries: conf.boundaries,
pings: conf.config.pings ?? conf.captureEvents.indexOf(MediaEventType.Ping) !== -1,
context: (args.context ?? []).concat([() => buildYouTubeEntity(conf)]),
timestamp: args.timestamp,
});
return conf.sessionId;
} catch (e) {
if (e instanceof Error) {
LOG?.error(e.message);
}
}
}
/**
* Disable tracking for a previously enabled player's media session.
* @param id The media session ID to disable tracking for
* @since 4.0.0
*/
export function endYouTubeTracking(id: string): void {
endMediaTracking({ id });
const intervalId = trackedPlayers[id].pollTracking.interval;
if (intervalId !== null) clearInterval(intervalId);
delete trackedPlayers[id];
}
/**
* Start media tracking for the given player and configuration.
* This is a legacy v3 API that wraps the newer `startYouTubeTracking` API.
* @param args Tracking configuration
* @returns The media session ID for the player
* @deprecated since v4.0.0; use {@link startYouTubeTracking}
*/
export function enableYouTubeTracking(args: LegacyYouTubeMediaTrackingConfiguration): string | void {
const { id, options, ...rest } = args;
const migrated: YouTubeMediaTrackingConfiguration = {
id: uuid(),
...rest,
...options,
video: id,
};
const sessionId = startYouTubeTracking(migrated);
if (sessionId) {
legacyApiSessions.push(sessionId);
return sessionId;
}
}
/**
* Disable tracking for a previously enabled player's media session.
* This is a legacy v3 API that wraps the newer `endYouTubeTracking` API.
* @param id The media session ID to disable tracking for, or the oldest known session ID if not provided
* @since 4.0.0
* @deprecated since v4.0.0; use {@link endYouTubeTracking}
*/
export function disableYouTubeTracking(id?: string): void {
if (id && legacyApiSessions.indexOf(id) !== -1) {
// id provided, remove from active list
legacyApiSessions.splice(legacyApiSessions.indexOf(id), 1);
} else {
// id was not provided; presume we're ending the earliest session we know of
id = id ?? legacyApiSessions.shift();
}
if (id) {
endYouTubeTracking(id);
}
}
/**
* Track a custom Self Describing Event with the Media plugin's entities included, like the events generated by the plugin directly.
* @param event The tracking options, including the custom Self Describing Event to track and optional media configuration such as `ad` data.
*/
export function trackYouTubeSelfDescribingEvent(
event: Omit<Parameters<typeof trackMediaSelfDescribingEvent>[0], 'player'>
) {
const config = trackedPlayers[event.id];
if (config) {
const yt = buildYouTubeEntity(config.conf);
trackMediaSelfDescribingEvent({
...event,
player: buildPlayerEntity(config.conf),
context: (event.context ?? []).concat(yt ?? []),
});
}
}
function addPlayer(conf: TrackingOptions): void {
const player = conf.video;
if (typeof player === 'string') {
// First check if the API is already active and a player exists with this name
// Creating an additional player will overwrite the existing one and fail
if (typeof YT !== 'undefined' && 'get' in YT) {
const existing: YT.Player | undefined = (YT as any).get(player);
if (existing) {
conf.video = existing;
return addPlayer(conf);
}
}
// May have meant a DOM ID?
const el = document.getElementById(player) as HTMLIFrameElement | null;
if (!el || el.tagName.toUpperCase() !== 'IFRAME') {
// Technically the IFrame API will replace non-iframe elements with an iframe
// In that case we expect the iframe/player to be passed directly instead
throw Error('Cannot find YouTube iframe or player');
}
conf.video = el;
return addPlayer(conf);
} else if ('tagName' in player) {
if (player.tagName.toUpperCase() !== 'IFRAME') throw Error('Only existing YouTube iframe elements are supported');
conf.urlParameters = parseUrlParams(ensureYouTubeIframeAPI(player));
// put them into a queue that will have listeners added once the API is ready
// and start trying to load the iframe API
trackingQueue.push(conf);
installYouTubeIframeAPI();
} else {
conf.urlParameters = parseUrlParams(player.getIframe().src);
conf.player = player;
initializePlayer(conf);
}
}
function ensureYouTubeIframeAPI(el: HTMLIFrameElement) {
// The 'enablejsapi' parameter is required to be '1' for the API to be able to communicate with the player
// It can be a URL parameter or an attribute of the iframe
// We only enable it if it hasn't been explicitly disabled/set
if (el.src.indexOf('enablejsapi') === -1 && el.getAttribute('enablejsapi') == null) {
let apiEnabled = addUrlParam(el.src, 'enablejsapi', '1');
// `origin` is recommended when using `enablejsapi`
if (apiEnabled.indexOf('origin=') === -1 && location.origin) {
apiEnabled = addUrlParam(apiEnabled, 'origin', location.origin);
}
el.src = apiEnabled;
}
return el.src;
}
function installYouTubeIframeAPI(iframeAPIRetryWaitMs: number = 100) {
// Once the API is ready to use, 'YT.Player' will be defined
// 'YT.Player' is not available immediately after 'YT' is defined,
// so we need to wait until 'YT' is defined to then check 'YT.Player'
if (typeof YT === 'undefined' || typeof YT.Player === 'undefined') {
// First we check if the script tag exists in the DOM, and enable the API if not
const scriptTags = Array.prototype.slice.call(document.getElementsByTagName('script'));
if (!scriptTags.some((s) => s.src === YouTubeIFrameAPIURL)) {
// Load the Iframe API
// https://developers.google.com/youtube/iframe_api_reference
const tag: HTMLScriptElement = document.createElement('script');
tag.src = YouTubeIFrameAPIURL;
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode!.insertBefore(tag, firstScriptTag);
}
// The iframe API usually loads a second script, so `onload` is not reliable
// We prefer not to pollute global scope so won't use onYouTubeIframeAPIReady either
// Poll the API to see when it exists
if (iframeAPIRetryWaitMs <= 6400) {
setTimeout(installYouTubeIframeAPI, iframeAPIRetryWaitMs, iframeAPIRetryWaitMs * 2);
} else {
LOG?.error('YouTube iframe API failed to load');
}
} else {
// Once the API is available, listeners are attached to anything sitting in the queue
while (trackingQueue.length) {
initializePlayer(trackingQueue.pop()!);
}
}
}
/**
* Adds a player to the list of tracked players, and sets up the `onReady` callback to attach listeners once the player is ready
*
* @param conf - The configuration for the player
*/
function initializePlayer(conf: TrackingOptions) {
const attach: YT.PlayerEventHandler<YT.PlayerEvent> = ({ target }) => {
conf.player = target;
trackedPlayers[conf.sessionId] = {
player: target,
conf,
// initial state for polling listeners
pollTracking: {
interval: null,
prevTime: target.getCurrentTime() || 0,
prevVolume: target.getVolume() || 0,
},
};
attachListeners(conf);
};
if (!conf.player) {
// Player API not yet initialized
if (typeof conf.video !== 'string' && 'tagName' in conf.video) {
conf.player = new YT.Player(conf.video, {
events: {
onReady: attach,
},
});
} else throw Error('Could not access YouTube Player API');
} else if (typeof conf.player.getCurrentTime === 'function') {
// Player API initialized and already "ready"
attach({ target: conf.player });
} else {
// Player API initialized but not yet "ready"
conf.player.addEventListener('onReady', attach);
}
}
function attachListeners(conf: TrackingOptions) {
const id = conf.sessionId,
player = conf.player!;
let buffering = false;
// object lookup should be faster than captureEvents.indexOf every time
const flags = conf.captureEvents.reduce((a: Partial<Record<MediaEventType, true>>, e) => ((a[e] = true), a), {});
const builtInEvents: Record<
keyof YT.Events,
| YT.PlayerEventHandler<YT.PlayerEvent>
| YT.PlayerEventHandler<YT.OnStateChangeEvent>
| YT.PlayerEventHandler<YT.OnErrorEvent>
| YT.PlayerEventHandler<YT.OnPlaybackQualityChangeEvent>
> = {
onStateChange: (e: YT.OnStateChangeEvent) => {
if (buffering && e.data !== YT.PlayerState.BUFFERING && flags[MediaEventType.BufferEnd]) {
trackMediaBufferEnd({ id });
buffering = false;
}
switch (e.data) {
case YT.PlayerState.CUED:
return;
case YT.PlayerState.UNSTARTED:
return flags[MediaEventType.Ready] && trackMediaReady({ id });
case YT.PlayerState.BUFFERING:
buffering = true;
return flags[MediaEventType.BufferEnd] && trackMediaBufferStart({ id }); // not a typo
case YT.PlayerState.PLAYING:
return flags[MediaEventType.Play] && trackMediaPlay({ id });
case YT.PlayerState.PAUSED:
return flags[MediaEventType.Pause] && trackMediaPause({ id });
case YT.PlayerState.ENDED:
return flags[MediaEventType.End] && trackMediaEnd({ id });
}
},
onPlaybackQualityChange: (e: YT.OnPlaybackQualityChangeEvent) =>
flags[MediaEventType.QualityChange] && trackMediaQualityChange({ id, newQuality: e.data }),
onApiChange: () => {},
onError: (e: YT.OnErrorEvent) =>
flags[MediaEventType.Error] &&
trackMediaError({ id, errorCode: String(e.data), errorName: YT.PlayerError[e.data] }),
onPlaybackRateChange: () =>
flags[MediaEventType.PlaybackRateChange] &&
trackMediaPlaybackRateChange({ id, newRate: player.getPlaybackRate() }),
onReady: () => {},
};
// The 'ready' event is required for modelling purposes, the Out-The-Box YouTube modelling won't work without it
// Ensure you have 'ready' in your 'captureEvents' array if you are using the Out-The-Box YouTube modelling
if (flags[MediaEventType.Ready]) {
// We need to manually trigger the 'ready' event, as it is already in that state and won't call the event listener
// do it in a new task because we probably haven't called startMediaTracking yet
setTimeout(() => trackMediaReady({ id: conf.sessionId, player: buildPlayerEntity(conf) }), 0);
}
conf.youtubeEvents.forEach((youtubeEventName) => {
player.addEventListener(youtubeEventName, (e) => {
updateMediaTracking({ id, player: buildPlayerEntity(conf) });
builtInEvents[youtubeEventName](e as any);
});
});
// there are actually `onVideoProgress` and `onVolumeChange` events but they
// are not documented so we will poll
installPollingListeners(conf, flags);
}
function installPollingListeners(conf: TrackingOptions, flags: Partial<Record<MediaEventType, true>>) {
const pollState = trackedPlayers[conf.sessionId].pollTracking;
const player = conf.player;
const id = conf.sessionId;
const enableSeek = flags[MediaEventType.SeekStart] || flags[MediaEventType.SeekEnd];
const enableVolume = flags[MediaEventType.VolumeChange];
if (!pollState.interval && player) {
pollState.interval = setInterval(() => {
updateMediaTracking({ id, player: buildPlayerEntity(conf) });
// Seek Tracking
if (enableSeek) {
const playerTime = player.getCurrentTime();
if (Math.abs(playerTime - (pollState.prevTime + conf.updateRate / 1000)) > 1) {
trackMediaSeekStart({ id, player: { currentTime: pollState.prevTime } });
trackMediaSeekEnd({ id, player: { currentTime: playerTime } });
}
pollState.prevTime = playerTime;
}
// Volume Tracking
if (enableVolume) {
const playerVolume = player.getVolume();
if (playerVolume !== pollState.prevVolume) {
trackMediaVolumeChange({ id, previousVolume: pollState.prevVolume, newVolume: playerVolume });
}
pollState.prevVolume = playerVolume;
}
}, conf.updateRate);
}
}