snowplow/snowplow-javascript-tracker

View on GitHub
plugins/browser-plugin-media-tracking/src/index.ts

Summary

Maintainability
B
6 hrs
Test Coverage
/*
 * Copyright (c) 2022 Snowplow Analytics Ltd
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its
 *    contributors may be used to endorse or promote products derived from
 *    this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
import { isType, boundaryErrorHandling, trackingOptionsParser } from './helperFunctions';
import { DocumentEvent, MediaEvent, SnowplowEvent, TextTrackEvent } from './mediaEvents';
import { TrackingOptions, MediaTrackingOptions, TrackedElement, EventDetail } from './types';
import { BrowserPlugin, BrowserTracker, dispatchToTrackersInCollection } from '@snowplow/browser-tracker-core';
import { buildSelfDescribingEvent, CommonEventProperties, Logger, SelfDescribingJson } from '@snowplow/tracker-core';
import { MediaPlayerEvent } from './contexts';
import { findMediaElem } from './findMediaElement';
import { buildMediaEvent } from './buildMediaEvent';
import { SEARCH_ERROR } from './constants';

declare global {
  interface HTMLVideoElement {
    autoPictureInPicture: boolean;
    disableRemotePlayback: boolean;
    disablePictureInPicture: boolean;
  }
  interface HTMLAudioElement {
    disableRemotePlayback: boolean;
  }
  interface Document {
    pictureInPictureElement: Element;
    msFullscreenElement: Element;
  }
}

let LOG: Logger;
const _trackers: Record<string, BrowserTracker> = {};

export function MediaTrackingPlugin(): BrowserPlugin {
  return {
    activateBrowserPlugin: (tracker: BrowserTracker) => {
      _trackers[tracker.id] = tracker;
    },
    logger: (logger) => {
      LOG = logger;
    },
  };
}

const trackedIds: Record<string, TrackedElement> = {};

// IE doesn't assign the 'target' property to the element a fullscreen event was called from,
// so need to track the element that is fullscreen, so when leaving fullscreen, we can
// check which id to track the event for (as document.fullscreenElement is null at that point)
let fullscreenElementId: string = '';

export function enableMediaTracking(args: { id: string; options?: MediaTrackingOptions }) {
  const conf: TrackingOptions = trackingOptionsParser(args.id, args.options);

  // Some events have additional effects/need to perform checks before running mediaPlayerEvent
  const eventsWithOtherFunctions: Record<string, Function> = {
    [DocumentEvent.FULLSCREENCHANGE]: (e: string, el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions) => {
      // If there is no fullscreen element, it means we are entering fullscreen
      if (document.fullscreenElement || document.msFullscreenElement) {
        fullscreenElementId = document.fullscreenElement?.id || document.msFullscreenElement?.id;
      }
      if (fullscreenElementId === el.id) {
        mediaPlayerEvent(e, el, conf);
      }
    },
    [MediaEvent.SEEKED]: (eventName: string, el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions) => {
      if (conf.captureEvents.indexOf(SnowplowEvent.PERCENTPROGRESS) !== 0) {
        while (conf.progress!.boundaryTimeoutIds.length) {
          clearTimeout(conf.progress!.boundaryTimeoutIds.pop() as ReturnType<typeof setTimeout>);
        }
        mediaPlayerEvent(eventName, el, conf);
        setPercentageBoundTimeouts(el, conf);
      }
    },
  };

  const eventHandlers: Record<string, Function> = {};
  conf.captureEvents.forEach((ev) => {
    if (eventsWithOtherFunctions.hasOwnProperty(ev)) {
      eventHandlers[ev] = (e: string, el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions, event: Event) =>
        eventsWithOtherFunctions[ev](e, el, conf, event);
    } else {
      eventHandlers[ev] = (e: string, el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions) =>
        mediaPlayerEvent(e, el, conf);
    }
  });

  trackedIds[args.id] = { waitTime: 250, retryCount: 5, tracking: false };
  setUpListeners(args.id, conf, eventHandlers);
}

function setUpListeners(id: string, conf: TrackingOptions, eventHandlers: Record<string, Function>) {
  // The element may not be loaded in time for this function to run, so we have a few goes at finding the element
  const result = findMediaElem(id);

  if (!trackedIds[id].retryCount) {
    LOG.error(result.err || SEARCH_ERROR.NOT_FOUND);
    return;
  }

  if (!result.el) {
    trackedIds[id].retryCount--;
    trackedIds[id].timeoutId = setTimeout(() => setUpListeners(id, conf, eventHandlers), trackedIds[id].waitTime);
    trackedIds[id].waitTime *= 2;
    return;
  }

  clearTimeout(trackedIds[id].timeoutId as ReturnType<typeof setTimeout>);

  if (!trackedIds[id].tracking) {
    if (conf.captureEvents.indexOf(SnowplowEvent.PERCENTPROGRESS) !== 0) {
      boundaryErrorHandling(conf.progress!.boundaries);
      setPercentageBoundTimeouts(result.el, conf);
    }
    addCaptureEventListeners(result.el, conf, eventHandlers);
    trackedIds[id].tracking = true;
  }
}

function addCaptureEventListeners(
  el: HTMLAudioElement | HTMLVideoElement,
  conf: TrackingOptions,
  eventHandlers: Record<string, Function>
): void {
  if (conf.captureEvents.indexOf(SnowplowEvent.READY) !== -1) {
    mediaPlayerEvent(SnowplowEvent.READY, el, conf);
  }
  conf.captureEvents.forEach((e) => {
    if (isType(e, TextTrackEvent)) {
      // IE10 doesn't support addEventListener for TextTrack objects
      if (el.textTracks.hasOwnProperty('addEventListener')) {
        el.textTracks.addEventListener(e, () => eventHandlers[e](e, el, conf));
      }
    } else if (isType(e, DocumentEvent)) {
      // The event emitted by the listener is needed for fullscreen tracking, as on exiting fullscreen
      // document.fullscreenElement is null. The id is needed to check if the event should be tracked
      ['MSFullscreenChange', 'fullscreenchange'].forEach((p: string) => {
        document.addEventListener(p, () => eventHandlers[e](e, el, conf));
      });
    } else {
      el.addEventListener(e, () => eventHandlers[e](e, el, conf));
    }
  });
}

function mediaPlayerEvent(
  e: string,
  el: HTMLAudioElement | HTMLVideoElement,
  conf: TrackingOptions,
  detail?: EventDetail
): void {
  const event = buildMediaEvent(e, el, conf, detail);
  if (conf.captureEvents.indexOf(SnowplowEvent.PERCENTPROGRESS) !== -1) {
    progressHandler(e, el, conf);
  }

  // Dragging the volume scrubber will generate a lot of events, this limits the rate at which
  // volume events can be sent at
  if (e === MediaEvent.VOLUMECHANGE) {
    clearTimeout(conf.volume!.eventTimeoutId as ReturnType<typeof setTimeout>);
    conf.volume!.eventTimeoutId = setTimeout(() => trackMediaEvent(event), conf.volume!.trackingInterval);
  } else {
    trackMediaEvent(event);
  }
}

function trackMediaEvent(
  event: SelfDescribingJson<MediaPlayerEvent> & CommonEventProperties,
  trackers: Array<string> = Object.keys(_trackers)
): void {
  dispatchToTrackersInCollection(trackers, _trackers, (t) => {
    t.core.track(buildSelfDescribingEvent({ event }), event.context, event.timestamp);
  });
}

// Progress Tracking

function progressHandler(e: string, el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions) {
  // If the event is a pause event, we need to clear any existing progress timeouts, and set them again on play
  if (e === MediaEvent.PAUSE) {
    while (conf.progress!.boundaryTimeoutIds.length) {
      clearTimeout(conf.progress!.boundaryTimeoutIds.pop() as ReturnType<typeof setTimeout>);
    }
  }
  if (e === MediaEvent.PLAY && (el as HTMLAudioElement | HTMLVideoElement).readyState > 0) {
    setPercentageBoundTimeouts(el as HTMLAudioElement | HTMLVideoElement, conf);
  }
}

function setPercentageBoundTimeouts(el: HTMLAudioElement | HTMLVideoElement, conf: TrackingOptions) {
  conf.progress!.boundaries.forEach((boundary) => {
    const absoluteBoundaryTimeMs = el.duration * (boundary / 100) * 1000;
    const timeUntilBoundaryEvent = absoluteBoundaryTimeMs - el.currentTime * 1000;
    // If the boundary is less than the current time, we don't need to bother setting it
    if (0 < timeUntilBoundaryEvent) {
      conf.progress!.boundaryTimeoutIds.push(
        setTimeout(
          () => waitAnyRemainingTimeAfterTimeout(el, absoluteBoundaryTimeMs, boundary, conf),
          timeUntilBoundaryEvent
        )
      );
    }
  });
}

// The timeout in setPercentageBoundTimeouts fires ~100 - 300ms early
// waitAnyRemainingTimeAfterTimeout ensures the event is fired accurately

function waitAnyRemainingTimeAfterTimeout(
  el: HTMLAudioElement | HTMLVideoElement,
  absoluteBoundaryTimeMs: number,
  boundary: number,
  conf: TrackingOptions
) {
  if (el.currentTime * 1000 < absoluteBoundaryTimeMs) {
    setTimeout(() => {
      waitAnyRemainingTimeAfterTimeout(el, absoluteBoundaryTimeMs, boundary, conf);
    }, 10);
  } else {
    mediaPlayerEvent(SnowplowEvent.PERCENTPROGRESS, el, conf, { boundary: boundary });
  }
}