diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index e66964e182c..5d96510eb4e 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -431,7 +431,6 @@ export const SelfHostedVideo = ({ null, ); const [hasPageBecomeActive, setHasPageBecomeActive] = useState(false); - const [hasTrackedPlay, setHasTrackedPlay] = useState(false); const [width, setWidth] = useState(); const [height, setHeight] = useState(); const [optimisedSources, setOptimisedSources] = useState([]); @@ -544,7 +543,10 @@ export const SelfHostedVideo = ({ currentTime, }); - const trackMilestones = useVideoMilestoneTracking(sendOphanTrackingEvent); + const [trackMilestones, resetMilestones] = useVideoMilestoneTracking( + sendOphanTrackingEvent, + videoStyle === 'Default', + ); const playVideo = useCallback(async () => { const video = vidRef.current; @@ -608,10 +610,8 @@ export const SelfHostedVideo = ({ } } else { void playVideo(); - if (hasTrackedPlay) { + if (playerState !== 'NOT_STARTED' && playerState !== 'ENDED') { sendOphanTrackingEvent('resume'); - } else { - sendOphanTrackingEvent('play'); } } }; @@ -937,11 +937,7 @@ export const SelfHostedVideo = ({ * Track the first successful video play in Ophan. */ const handlePlaying = () => { - if (hasTrackedPlay) { - return; - } - sendOphanTrackingEvent('play'); - setHasTrackedPlay(true); + trackMilestones({ started: true }); }; const handlePlayPauseClick = (event: React.SyntheticEvent) => { @@ -1029,6 +1025,12 @@ export const SelfHostedVideo = ({ pauseVideo('PAUSED_BY_BROWSER'); }; + const handleEnded = () => { + trackMilestones({ ended: true }); + resetMilestones(); + setPlayerState('ENDED'); + }; + /** * If the video could not be loaded due to an error, report to * Sentry and log in the console. @@ -1078,13 +1080,17 @@ export const SelfHostedVideo = ({ if (playerState === 'PLAYING') { setCurrentTime(video.currentTime); - /** * We only want to track milestone events for "long-form" * videos, not loops or cinemagraphs. */ - if (videoStyle === 'Default') { - trackMilestones(video.currentTime, video.duration); + trackMilestones({ + currentTime: video.currentTime, + duration: video.duration, + }); + + if (video.currentTime < 1) { + resetMilestones(); } } }; @@ -1206,6 +1212,7 @@ export const SelfHostedVideo = ({ } handlePause={handlePause} handleFullscreenClick={handleFullscreenClick} + handleEnded={handleEnded} updateCurrentTime={updateCurrentTime} onError={onError} preloadPartialData={!!shouldAutoplay} diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 0727c28f3fd..9d9fd1228c5 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -98,6 +98,7 @@ export const PLAYER_STATES = [ * For example, iOS devices in low power mode will suspend playback on autoplaying videos. */ 'PAUSED_BY_BROWSER', + 'ENDED', ] as const; export type PlayerStates = (typeof PLAYER_STATES)[number]; @@ -123,6 +124,7 @@ export type Props = { handleTimeUpdate: (event: SyntheticEvent) => void; handlePause: (event: SyntheticEvent) => void; handleFullscreenClick?: (event: SyntheticEvent) => void; + handleEnded?: (event: SyntheticEvent) => void; updateCurrentTime: (time: number) => void; onError: (event: SyntheticEvent) => void; posterImage?: string; @@ -177,6 +179,7 @@ export const SelfHostedVideoPlayer = forwardRef( handleTimeUpdate, handlePause, handleFullscreenClick, + handleEnded, updateCurrentTime, onError, preloadPartialData, @@ -238,6 +241,7 @@ export const SelfHostedVideoPlayer = forwardRef( onClick={handlePlayPauseClick} onKeyDown={handleKeyDown} onError={onError} + onEnded={handleEnded} disablePictureInPicture={true} > {sources.map(({ src, mimeType }) => ( diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx index cc9640bc4c9..640036a350e 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -45,11 +45,6 @@ type Props = { renderingTarget: RenderingTarget; }; -type ProgressEvents = { - hasSentPlayEvent: boolean; - hasSentEndEvent: boolean; -}; - /** * Player listeners e.g. * name: onReady, onStateChange, etc... @@ -194,9 +189,13 @@ const createOnStateChangeListener = ( videoId: string, uniqueId: string, - progressEvents: ProgressEvents, + playerState: { + paused: boolean; + progressIntervalId: ReturnType | undefined; + }, sendOphanTrackingEvent: (event: VideoEventKey) => void, - trackMilestones: (currentTime: number, duration: number) => void, + trackMilestones: ReturnType[0], + resetMilestones: () => void, ): YT.PlayerEventHandler => (event) => { const loggerFrom = 'YoutubeAtomPlayer onStateChange'; @@ -218,44 +217,24 @@ const createOnStateChangeListener = */ dispatchCustomPlayEvent(uniqueId); - if (!progressEvents.hasSentPlayEvent) { - log('dotcom', { - from: loggerFrom, - videoId, - msg: 'start play', - event, - }); - sendOphanTrackingEvent('play'); - progressEvents.hasSentPlayEvent = true; - - /** - * Set a timeout to check progress again in the future - */ - setTimeout(() => { - checkProgress(); - }, 3000); - } else { - log('dotcom', { - from: loggerFrom, - videoId, - msg: 'resume', - event, - }); + trackMilestones({ started: true }); + if (playerState.paused) { sendOphanTrackingEvent('resume'); } - const checkProgress = () => { - trackMilestones(player.getCurrentTime(), player.getDuration()); + playerState.paused = false; - if (player.getPlayerState() !== YT.PlayerState.ENDED) { - /** - * Set a timeout to check progress again in the future - */ - setTimeout(() => checkProgress(), 3000); - } + clearInterval(playerState.progressIntervalId); + playerState.progressIntervalId = setInterval(() => { + trackMilestones({ + currentTime: player.getCurrentTime(), + duration: player.getDuration(), + }); - return null; - }; + if (player.getPlayerState() === YT.PlayerState.ENDED) { + clearInterval(playerState.progressIntervalId); + } + }, 3000); } if (event.data === YT.PlayerState.PAUSED) { @@ -268,6 +247,8 @@ const createOnStateChangeListener = event, }); sendOphanTrackingEvent('pause'); + playerState.paused = true; + clearInterval(playerState.progressIntervalId); } if (event.data === YT.PlayerState.CUED) { @@ -278,24 +259,13 @@ const createOnStateChangeListener = event, }); sendOphanTrackingEvent('cued'); - progressEvents.hasSentPlayEvent = false; } - if ( - event.data === YT.PlayerState.ENDED && - !progressEvents.hasSentEndEvent - ) { + if (event.data === YT.PlayerState.ENDED) { dispatchCustomPauseEvent(uniqueId); - - log('dotcom', { - from: loggerFrom, - videoId, - msg: 'ended', - event, - }); - sendOphanTrackingEvent('end'); - progressEvents.hasSentEndEvent = true; - progressEvents.hasSentPlayEvent = false; + clearInterval(playerState.progressIntervalId); + trackMilestones({ ended: true }); + resetMilestones(); } }; @@ -441,12 +411,13 @@ export const YoutubeAtomPlayer = ({ }, [eventEmitters], ); - const trackMilestones = useVideoMilestoneTracking(sendOphanTrackingEvent); - - const progressEvents = useRef({ - hasSentPlayEvent: false, - hasSentEndEvent: false, - }); + const [trackMilestones, resetMilestones] = useVideoMilestoneTracking( + sendOphanTrackingEvent, + ); + const playerPauseState = useRef<{ + paused: boolean; + progressIntervalId: ReturnType | undefined; + }>({ paused: false, progressIntervalId: undefined }); const [playerReady, setPlayerReady] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); @@ -485,9 +456,10 @@ export const YoutubeAtomPlayer = ({ const onStateChangeListener = createOnStateChangeListener( videoId, uniqueId, - progressEvents.current, + playerPauseState.current, sendOphanTrackingEvent, trackMilestones, + resetMilestones, ); /** @@ -648,6 +620,7 @@ export const YoutubeAtomPlayer = ({ origin, playerReadyCallback, renderingTarget, + resetMilestones, trackMilestones, uniqueId, videoId, diff --git a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index 3639ce37945..6c8daf85ac1 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -2,46 +2,102 @@ import { useCallback, useRef } from 'react'; import type { VideoEventKey } from '../components/YoutubeAtom/YoutubeAtom'; type Milestones = { + hasSentPlay: boolean; hasSent25: boolean; hasSent50: boolean; hasSent75: boolean; + hasSentEnd: boolean; }; /** - * Returns a function that should be called on each time update. - * It tracks when a video crosses the 25%, 50% and 75% milestones and - * calls the provided callback for each. + * Returns two functions, the first of which should be called on each time update. + * It tracks when a video begins, ends or crosses the 25%, 50% and 75% + * milestones. On each milestone the provided callback is triggered. + * The second function can be used to reset the internal state of the hook, + * allowing milestones to be again if a video is played multiple times. */ export const useVideoMilestoneTracking = ( onMilestone: (event: VideoEventKey) => void, -): ((currentTime: number, duration: number) => void) => { - const milestones = useRef({ + trackPercentageMilestones: boolean = true, +): [ + ( + progress: + | { currentTime: number; duration: number } + | { started: true } + | { ended: true }, + ) => void, + () => void, +] => { + const clearMilestones = () => ({ + hasSentPlay: false, hasSent25: false, hasSent50: false, hasSent75: false, + hasSentEnd: false, }); - return useCallback( - (currentTime: number, duration: number) => { - if (duration <= 0) return; + const milestones = useRef(clearMilestones()); - const percent = (currentTime / duration) * 100; + /** + * Sends tracking events when video milestones are hit. + * Uses hook state to prevent duplicate tracking events from being fired. + * Can be called on start, end or timestamp update events using the + * appropriate parameters. + */ + const trackMilestone = useCallback( + ( + progress: + | { currentTime: number; duration: number } + | { started: true } + | { ended: true }, + ) => { + let percent = 0; - if (!milestones.current.hasSent25 && percent >= 25) { - onMilestone('25'); - milestones.current.hasSent25 = true; + if ('ended' in progress) { + percent = 100; + } else if ('duration' in progress && progress.duration > 0) { + percent = (progress.currentTime / progress.duration) * 100; } - if (!milestones.current.hasSent50 && percent >= 50) { - onMilestone('50'); - milestones.current.hasSent50 = true; + if (!milestones.current.hasSentPlay && percent >= 0) { + onMilestone('play'); + milestones.current.hasSentPlay = true; } - if (!milestones.current.hasSent75 && percent >= 75) { - onMilestone('75'); - milestones.current.hasSent75 = true; + if (trackPercentageMilestones) { + if (!milestones.current.hasSent25 && percent >= 25) { + onMilestone('25'); + milestones.current.hasSent25 = true; + } + + if (!milestones.current.hasSent50 && percent >= 50) { + onMilestone('50'); + milestones.current.hasSent50 = true; + } + + if (!milestones.current.hasSent75 && percent >= 75) { + onMilestone('75'); + milestones.current.hasSent75 = true; + } + } + + if (!milestones.current.hasSentEnd && percent >= 99) { + onMilestone('end'); + milestones.current.hasSentEnd = true; } }, - [onMilestone], + [onMilestone, trackPercentageMilestones], ); + + /** + * Resets the milestones, allowing further tracking events to be triggered. + * Will not reset if the video has not reached the end. + */ + const resetMilestones = () => { + if (milestones.current.hasSentEnd) { + milestones.current = clearMilestones(); + } + }; + + return [trackMilestone, resetMilestones]; };