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 = {}; const trackingQueue: Array = []; 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[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 = ({ 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>, e) => ((a[e] = true), a), {}); const builtInEvents: Record< keyof YT.Events, | YT.PlayerEventHandler | YT.PlayerEventHandler | YT.PlayerEventHandler | YT.PlayerEventHandler > = { 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>) { 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); } }