snowplow/snowplow-javascript-tracker

View on GitHub
plugins/browser-plugin-youtube-tracking/src/initialization.ts

Summary

Maintainability
A
0 mins
Test Coverage
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);
  }
}