import type { PlayerEmbed } from '../..'; import type { VideoEventDetail } from '@vouchfor/media-player-legacy'; import type { ReactiveController, ReactiveControllerHost } from 'lit'; import { findVouchId, getReportingMetadata, getUids } from './utils'; import { getEnvUrls } from '~/utils/env'; const MINIMUM_SEND_THRESHOLD = 1; type PlayerEmbedHost = ReactiveControllerHost & PlayerEmbed; type TrackingEvent = 'VOUCH_LOADED' | 'VOUCH_RESPONSE_VIEWED' | 'VIDEO_PLAYED' | 'VIDEO_STREAMED'; type TrackingPayload = { vouchId?: string; answerId?: string; streamStart?: number; streamEnd?: number; senderId?: string; }; type BatchEvent = { event: TrackingEvent; payload: TrackingPayload & { time: string; }; }; type TimeMap = { [key: string]: number; }; type BooleanMap = { [key: string]: boolean; }; class TrackingController implements ReactiveController { host: PlayerEmbedHost; private _batchedEvents: BatchEvent[] = []; private _hasPlayed = false; private _hasLoaded: BooleanMap = {}; private _answersViewed: BooleanMap = {}; private _streamStartTime: TimeMap = {}; private _streamLatestTime: TimeMap = {}; private _currentlyPlayingVideo: VideoEventDetail | null = null; constructor(host: PlayerEmbedHost) { this.host = host; host.addController(this); } private _createTrackingEvent = (event: TrackingEvent, payload?: TrackingPayload) => { const vouchId = findVouchId(payload, this.host.vouch); if (!vouchId || this.host.disableTracking) { return; } this._batchedEvents.push({ event, payload: { ...payload, senderId: this.host.senderId, vouchId, time: new Date().toISOString() } }); }; private _sendTrackingEvent = () => { if (this._batchedEvents.length <= 0) { return; } const { publicApiUrl } = getEnvUrls(this.host.env); const { client, tab, request, visitor } = getUids(this.host.env); navigator.sendBeacon( `${publicApiUrl}/api/batchevents`, JSON.stringify({ payload: { events: this._batchedEvents }, context: { 'x-uid-client': client, 'x-uid-tab': tab, 'x-uid-request': request, 'x-uid-visitor': visitor, 'x-reporting-metadata': getReportingMetadata(this.host.trackingSource) } }) ); this._batchedEvents = []; }; private _streamEnded = () => { if (this._currentlyPlayingVideo) { const { id, key } = this._currentlyPlayingVideo; // Don't send a tracking event when seeking backwards if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) { // Send a video streamed event any time the stream ends to capture the time between starting // the video and the video stopping for any reason (pausing, deleting the embed node or closing the browser) this._createTrackingEvent('VIDEO_STREAMED', { answerId: id, streamStart: this._streamStartTime[key], streamEnd: this._streamLatestTime[key] }); } // Make sure these events are only sent once by deleting the start and latest times delete this._streamStartTime[key]; delete this._streamLatestTime[key]; } }; private _handleVouchLoaded = ({ detail: vouchId }: CustomEvent) => { if (!vouchId) { return; } // Only send loaded event once per session if (!this._hasLoaded[vouchId]) { this._createTrackingEvent('VOUCH_LOADED', { vouchId }); this._hasLoaded[vouchId] = true; } }; private _handlePlay = () => { // Only send the video played event once per session if (!this._hasPlayed) { this._createTrackingEvent('VIDEO_PLAYED', { streamStart: this.host.currentTime }); this._hasPlayed = true; } }; private _handleVideoPlay = ({ detail: { id, key } }: CustomEvent) => { // Only increment play count once per session if (!this._answersViewed[key]) { this._createTrackingEvent('VOUCH_RESPONSE_VIEWED', { answerId: id }); this._answersViewed[key] = true; } }; private _handleVideoTimeUpdate = ({ detail: { id, key, node } }: CustomEvent) => { if ( // We only want to count any time that the video is actually playing !this.host.paused && // Only update the latest time if this event fires for the currently active video id === this.host.scene?.video?.id ) { this._currentlyPlayingVideo = { id, key, node }; this._streamLatestTime[key] = node.currentTime; if (!this._streamStartTime[key]) { this._streamStartTime[key] = node.currentTime; this._streamLatestTime[key] = node.currentTime; } } }; private _handleVideoPause = ({ detail: { id, key } }: CustomEvent) => { if (this._streamLatestTime[key] > this._streamStartTime[key] + MINIMUM_SEND_THRESHOLD) { this._createTrackingEvent('VIDEO_STREAMED', { answerId: id, streamStart: this._streamStartTime[key], streamEnd: this._streamLatestTime[key] }); } delete this._streamStartTime[key]; delete this._streamLatestTime[key]; }; private _pageUnloading = () => { this._streamEnded(); this._sendTrackingEvent(); }; private _handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { this._pageUnloading(); } }; private _handlePageHide = () => { this._pageUnloading(); }; hostConnected() { requestAnimationFrame(() => { if ('onvisibilitychange' in document) { document.addEventListener('visibilitychange', this._handleVisibilityChange); } else { window.addEventListener('pagehide', this._handlePageHide); } this.host.addEventListener('vouch:loaded', this._handleVouchLoaded); this.host.mediaPlayer?.addEventListener('play', this._handlePlay); this.host.mediaPlayer?.addEventListener('video:play', this._handleVideoPlay); this.host.mediaPlayer?.addEventListener('video:pause', this._handleVideoPause); this.host.mediaPlayer?.addEventListener('video:timeupdate', this._handleVideoTimeUpdate); }); } hostDisconnected() { // Send events if DOM node is destroyed this._pageUnloading(); if ('onvisibilitychange' in document) { document.removeEventListener('visibilitychange', this._handleVisibilityChange); } else { window.removeEventListener('pagehide', this._handlePageHide); } this.host.removeEventListener('vouch:loaded', this._handleVouchLoaded); this.host.mediaPlayer?.removeEventListener('play', this._handlePlay); this.host.mediaPlayer?.removeEventListener('video:play', this._handleVideoPlay); this.host.mediaPlayer?.removeEventListener('video:pause', this._handleVideoPause); this.host.mediaPlayer?.removeEventListener('video:timeupdate', this._handleVideoTimeUpdate); } } export { TrackingController }; export type { TrackingEvent, TrackingPayload };