snowplow/snowplow-javascript-tracker

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

Summary

Maintainability
A
2 hrs
Test Coverage
import { resolveDynamicContext, DynamicContext, LOG, SelfDescribingJson } from '@snowplow/tracker-core';
import { MediaAdTracking } from './adTracking';
import { buildMediaPlayerEntity, buildMediaPlayerEvent } from './core';
import { MediaPingInterval } from './pingInterval';
import { MediaSessionTracking } from './sessionTracking';
import {
  MediaPlayer,
  MediaAdUpdate,
  MediaPlayerAdBreakUpdate,
  MediaPlayerUpdate,
  MediaEventType,
  MediaEvent,
  EventWithContext,
  FilterOutRepeatedEvents,
} from './types';
import { RepeatedEventFilter } from './repeatedEventFilter';

/**
 * Manages the state and built-in entities for a media tracking that starts when a user
 * call `startMediaTracking` and ends with `endMediaTracking`.
 *
 * It updates the internal state for each tracked event and returns context entities with
 * properties updated based on the internal state.
 */
export class MediaTracking {
  /// ID of the media tracking that is used to refer to it by the user.
  id: string;
  /// Percentage boundaries when to track percent progress events.
  private boundaries?: number[];
  /// List of boundaries for which percent progress events were already sent to avoid sending again.
  private sentBoundaries: number[] = [];
  /// State for the media player context entity that is updated as new events are tracked.
  player: MediaPlayer = {
    currentTime: 0,
    paused: true,
    ended: false,
  };

  /// Used to add media player session entity.
  private session?: MediaSessionTracking;
  /// Tracks ping events independently but stored here to stop when media tracking stops.
  private pingInterval?: MediaPingInterval;
  /// Manages ad entities.
  private adTracking = new MediaAdTracking();
  /// Context entities to attach to all events
  private customContext?: DynamicContext;
  /// Optional list of event types to allow tracking and discard others.
  private captureEvents?: MediaEventType[];
  // Whether to update page activity when playing media. Enabled by default.
  private updatePageActivityWhilePlaying?: boolean;
  /// Filters out repeated events based on the configuration.
  private repeatedEventFilter: RepeatedEventFilter;

  constructor(
    id: string,
    player?: MediaPlayerUpdate,
    session?: MediaSessionTracking,
    pingInterval?: MediaPingInterval,
    boundaries?: number[],
    captureEvents?: MediaEventType[],
    updatePageActivityWhilePlaying?: boolean,
    filterRepeatedEvents?: FilterOutRepeatedEvents,
    context?: DynamicContext
  ) {
    this.id = id;
    this.updatePlayer(player);
    this.session = session;
    this.pingInterval = pingInterval;
    this.boundaries = boundaries;
    this.captureEvents = captureEvents;
    this.updatePageActivityWhilePlaying = updatePageActivityWhilePlaying;
    this.customContext = context;
    this.repeatedEventFilter = new RepeatedEventFilter(filterRepeatedEvents);

    // validate event names in the captureEvents list
    captureEvents?.forEach((eventType) => {
      if (!Object.values(MediaEventType).includes(eventType)) {
        LOG.warn('Unknown media event ' + eventType);
      }
    });
  }

  /**
   * Called when user calls `endMediaTracking()`.
   */
  flushAndStop() {
    this.pingInterval?.clear();
    this.repeatedEventFilter.flush();
  }

  /**
   * Updates the internal state given the new event or new media player info and returns events to track.
   * @param eventType Type of the event tracked or undefined when only updating player properties.
   * @param player Updates to the media player stored entity.
   * @param ad Updates to the ad entity.
   * @param adBreak Updates to the ad break entity.
   * @returns List of events with entities to track.
   */
  update(
    trackEvent: (event: EventWithContext) => void,
    mediaEvent?: MediaEvent,
    customEvent?: SelfDescribingJson,
    player?: MediaPlayerUpdate,
    ad?: MediaAdUpdate,
    adBreak?: MediaPlayerAdBreakUpdate
  ) {
    // update state
    this.updatePlayer(player);
    if (mediaEvent !== undefined) {
      this.adTracking.updateForThisEvent(mediaEvent.type, this.player, ad, adBreak);
    }
    this.session?.update(mediaEvent?.type, this.player, this.adTracking.adBreak);
    this.pingInterval?.update(this.player);

    // build context entities
    let context = [buildMediaPlayerEntity(this.player)];
    if (this.session !== undefined) {
      context.push(this.session.getContext());
    }
    if (this.customContext) {
      context = context.concat(resolveDynamicContext(this.customContext, mediaEvent ?? customEvent));
    }
    context = context.concat(this.adTracking.getContext());

    // build event types to track
    const mediaEventsToTrack: MediaEvent[] = [];
    if (mediaEvent !== undefined && this.shouldTrackEvent(mediaEvent.type)) {
      mediaEventsToTrack.push(mediaEvent);
    }
    if (this.shouldSendPercentProgress()) {
      mediaEventsToTrack.push({
        type: MediaEventType.PercentProgress,
        eventBody: { percentProgress: this.getPercentProgress() },
      });
    }

    // update state for events after this one
    if (mediaEvent !== undefined) {
      this.adTracking.updateForNextEvent(mediaEvent.type);
    }

    const eventsToTrack = mediaEventsToTrack.map((event) => {
      return { event: buildMediaPlayerEvent(event), context: context };
    });
    if (customEvent !== undefined) {
      eventsToTrack.push({ event: customEvent, context: context });
    }

    this.repeatedEventFilter.trackFilteredEvents(eventsToTrack, trackEvent);
  }

  shouldUpdatePageActivity(): boolean {
    return (this.updatePageActivityWhilePlaying ?? true) && !this.player.paused;
  }

  private updatePlayer(player?: MediaPlayerUpdate) {
    if (player !== undefined) {
      this.player = {
        ...this.player,
        ...player,
      };
    }
  }

  private shouldSendPercentProgress(): boolean {
    const percentProgress = this.getPercentProgress();
    if (this.boundaries === undefined || percentProgress === undefined || this.player.paused) {
      return false;
    }

    let boundaries = this.boundaries.filter((b) => b <= (percentProgress ?? 0));
    if (boundaries.length == 0) {
      return false;
    }
    let boundary = Math.max(...boundaries);
    if (!this.sentBoundaries.includes(boundary)) {
      this.sentBoundaries.push(boundary);
      return true;
    }
    return false;
  }

  private shouldTrackEvent(eventType: MediaEventType): boolean {
    return this.captureEvents === undefined || this.captureEvents.includes(eventType);
  }

  private getPercentProgress(): number | undefined {
    if (this.player.duration === null || this.player.duration === undefined || this.player.duration == 0) {
      return undefined;
    }
    return Math.floor(((this.player.currentTime ?? 0) / this.player.duration) * 100);
  }
}