Skip to content

Commit

Permalink
✨ Add gtm events for video progress and termination #1999 (#2003)
Browse files Browse the repository at this point in the history
  • Loading branch information
fernandolucchesi authored Dec 1, 2023
1 parent 039ac0c commit f884d4d
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 22 deletions.
27 changes: 25 additions & 2 deletions web/components/src/HLSPlayer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/* eslint-disable import/no-named-as-default-member */
/* eslint-disable jsx-a11y/media-has-caption */
import { useRef, HTMLProps, useEffect, useState, useCallback, useMemo } from 'react'
import { useRef, HTMLProps, useEffect, useState, useCallback } from 'react'
import styled from 'styled-components'
import Hls from 'hls.js'
import { Icon } from '@equinor/eds-core-react'
import { play_circle, pause_circle } from '@equinor/eds-icons'
import { pushToDataLayer } from '../../../lib/gtm'
import useVideoAnalytics from '../../../lib/hooks/useVideoAnalytics'

type HLSProps = Omit<HTMLProps<HTMLVideoElement>, 'src'> & {
Expand Down Expand Up @@ -106,6 +105,26 @@ export const HLSPlayer: React.FC<HLSProps> = ({
autoStartLoad: autoPlay, // This ensures video is not loaded automatically
})
hlsRef.current = hls

hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// try to recover network error
console.error('Network error encountered', data)
break
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Media error encountered', data)
break
default:
// cannot recover
console.error('Unrecoverable error encountered', data)
hls.destroy()
break
}
}
})

hls.loadSource(src)
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
Expand All @@ -124,8 +143,12 @@ export const HLSPlayer: React.FC<HLSProps> = ({

return () => {
if (video) {
video.removeEventListener('play', handlePlayEvent)
video.removeAttribute('src')
}
if (hlsRef.current) {
hlsRef.current.destroy()
}
}
}, [autoPlay, src])

Expand Down
175 changes: 155 additions & 20 deletions web/lib/hooks/useVideoAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,181 @@
import { pushToDataLayer } from '../../lib/gtm'
import { useEffect, useMemo, useCallback, useState } from 'react'
import useConsentState from './useConsentState'

type GTMTitleType = { videoTitle: string | undefined }
type VideoRefType = React.RefObject<HTMLVideoElement>
import { pushToDataLayer } from '../../lib/gtm'
import { useEffect, useCallback, useState, RefObject } from 'react'

const GTM_PLAY_EVENT = 'video_play'
const GTM_PAUSE_EVENT = 'video_pause'
const GTM_COMPLETION_EVENT = 'video_completion'
const GTM_PROGRESS_MILESTONES = [25, 50, 75, 90] // Percentages

type VideoRefType = RefObject<HTMLVideoElement>
type EventType = typeof GTM_PLAY_EVENT | typeof GTM_PAUSE_EVENT | typeof GTM_COMPLETION_EVENT | string

type EventData = {
eventType: EventType
videoTitle: string
currentTime: number
src: string
videoType?: string
}

// Video Analytics Hook
const useVideoAnalytics = (videoRef: VideoRefType, src: string, title?: string): void => {
const [allowAnalytics, setAllowAnalytics] = useState(false)

useConsentState(
'statistics',
() => setAllowAnalytics(true),
() => setAllowAnalytics(false),
)

const GTM_TITLE: GTMTitleType = useMemo(() => ({ videoTitle: title || src }), [title, src])
const pushEventToDataLayer = useCallback(
(eventType: EventType, videoElement: HTMLVideoElement) => {
const eventData: EventData = {
eventType,
videoTitle: title || src,
videoType: videoElement.loop ? 'loop' : undefined,
currentTime: videoElement.currentTime,
src,
}
pushToDataLayer('video_event', eventData)
},
[title, src],
)

usePlayEvent(videoRef, pushEventToDataLayer, allowAnalytics)
usePauseEvent(videoRef, pushEventToDataLayer, allowAnalytics)
useCompletionEvent(videoRef, pushEventToDataLayer, allowAnalytics)
useCompletionEventForLoopingVideos(videoRef, pushEventToDataLayer, allowAnalytics)
useVideoProgressEvent(videoRef, pushEventToDataLayer, allowAnalytics)
}

const usePlayEvent = (
videoRef: VideoRefType,
pushEvent: (eventType: EventType, videoElement: HTMLVideoElement) => void,
allowAnalytics: boolean,
) => {
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return

const handlePlay = () => {
if (allowAnalytics) {
pushEvent(GTM_PLAY_EVENT, videoElement)
}
}
videoElement.addEventListener('play', handlePlay)

const handlePlayEvent = useCallback(() => {
pushToDataLayer(GTM_PLAY_EVENT, GTM_TITLE)
}, [GTM_TITLE])
return () => videoElement.removeEventListener('play', handlePlay)
}, [videoRef, pushEvent, allowAnalytics])
}

const handlePauseEvent = useCallback(() => {
pushToDataLayer(GTM_PAUSE_EVENT, GTM_TITLE)
}, [GTM_TITLE])
const usePauseEvent = (
videoRef: VideoRefType,
pushEvent: (eventType: EventType, videoElement: HTMLVideoElement) => void,
allowAnalytics: boolean,
) => {
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return

const handlePause = () => {
const isVideoEnded = videoElement.currentTime >= videoElement.duration
if (!isVideoEnded && allowAnalytics) {
pushEvent(GTM_PAUSE_EVENT, videoElement)
}
}
videoElement.addEventListener('pause', handlePause)

return () => videoElement.removeEventListener('pause', handlePause)
}, [videoRef, pushEvent, allowAnalytics])
}

const useCompletionEvent = (
videoRef: VideoRefType,
pushEvent: (eventType: EventType, videoElement: HTMLVideoElement) => void,
allowAnalytics: boolean,
) => {
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return

if (!videoElement || !allowAnalytics) return
const handleCompletion = () => {
if (allowAnalytics) {
pushEvent(GTM_COMPLETION_EVENT, videoElement)
}
}
videoElement.addEventListener('ended', handleCompletion)

return () => videoElement.removeEventListener('ended', handleCompletion)
}, [videoRef, pushEvent, allowAnalytics])
}

// Looping videos do not trigger 'ended' event listener
// This hook triggers completion when the video is about to loop
const useCompletionEventForLoopingVideos = (
videoRef: VideoRefType,
pushEvent: (eventType: EventType, videoElement: HTMLVideoElement) => void,
allowAnalytics: boolean,
) => {
const [hasTriggered, setHasTriggered] = useState(false)

useEffect(() => {
const videoElement = videoRef.current
if (!videoElement || !videoElement.loop || !allowAnalytics || hasTriggered) return

const threshold = 1 // Threshold in seconds to determine "near end"
const handleTimeUpdate = () => {
const timeLeft = videoElement.duration - videoElement.currentTime
const nearEnd = timeLeft < threshold

if (nearEnd && !hasTriggered) {
pushEvent(GTM_COMPLETION_EVENT, videoElement)
setHasTriggered(true) // Prevent further triggers
}
}

videoElement.addEventListener('timeupdate', handleTimeUpdate)

return () => videoElement.removeEventListener('timeupdate', handleTimeUpdate)
}, [videoRef, pushEvent, allowAnalytics, hasTriggered])
}

const useVideoProgressEvent = (
videoRef: VideoRefType,
pushEvent: (eventType: EventType, videoElement: HTMLVideoElement) => void,
allowAnalytics: boolean,
) => {
const [trackedMilestones, setTrackedMilestones] = useState<number[]>([])
const intervalDuration = 1000 // Check every second

useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return

const intervalId = setInterval(() => {
if (!allowAnalytics || !videoElement.duration) return

const progress = (videoElement.currentTime / videoElement.duration) * 100
GTM_PROGRESS_MILESTONES.forEach((milestone) => {
if (progress >= milestone && !trackedMilestones.includes(milestone)) {
pushEvent(`video_progress_${milestone}`, videoElement)
setTrackedMilestones((prev) => [...prev, milestone])
}
})
}, intervalDuration)

const handlePlay = () => {
if (videoElement.currentTime === 0 && !videoElement.loop) {
setTrackedMilestones([]) // Reset milestones at the start of a new play session
}
}

videoElement.addEventListener('play', handlePlayEvent)
videoElement.addEventListener('pause', handlePauseEvent)
videoElement.addEventListener('play', handlePlay)

// Clean up event listeners on unmount
return () => {
videoElement.removeEventListener('play', handlePlayEvent)
videoElement.removeEventListener('pause', handlePauseEvent)
clearInterval(intervalId)
videoElement.removeEventListener('play', handlePlay)
}
}, [allowAnalytics, videoRef, handlePlayEvent, handlePauseEvent])
}, [videoRef, pushEvent, allowAnalytics, trackedMilestones])
}

export default useVideoAnalytics

0 comments on commit f884d4d

Please sign in to comment.