From f884d4d7de5cb2a03507253be405faf1da88d860 Mon Sep 17 00:00:00 2001 From: Fernando Lucchesi Date: Fri, 1 Dec 2023 10:08:56 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20gtm=20events=20for=20video=20?= =?UTF-8?q?progress=20and=20termination=20#1999=20(#2003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/src/HLSPlayer/index.tsx | 27 +++- web/lib/hooks/useVideoAnalytics.ts | 175 ++++++++++++++++++++++--- 2 files changed, 180 insertions(+), 22 deletions(-) diff --git a/web/components/src/HLSPlayer/index.tsx b/web/components/src/HLSPlayer/index.tsx index 57ba42774..22fdcb1a4 100644 --- a/web/components/src/HLSPlayer/index.tsx +++ b/web/components/src/HLSPlayer/index.tsx @@ -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, 'src'> & { @@ -106,6 +105,26 @@ export const HLSPlayer: React.FC = ({ 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')) { @@ -124,8 +143,12 @@ export const HLSPlayer: React.FC = ({ return () => { if (video) { + video.removeEventListener('play', handlePlayEvent) video.removeAttribute('src') } + if (hlsRef.current) { + hlsRef.current.destroy() + } } }, [autoPlay, src]) diff --git a/web/lib/hooks/useVideoAnalytics.ts b/web/lib/hooks/useVideoAnalytics.ts index 0bc19b4e9..1dd9d1bbe 100644 --- a/web/lib/hooks/useVideoAnalytics.ts +++ b/web/lib/hooks/useVideoAnalytics.ts @@ -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 +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 +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([]) + 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