plugins/browser-plugin-media/src/mediaTracking.ts
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);
}
}