From 6cb9195faeedb9850085ce0227a729b2047d0932 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 13:57:54 +0100 Subject: [PATCH 1/8] Adds END milestone tracking to milestone hook --- .../src/components/SelfHostedVideo.island.tsx | 7 +++- .../YoutubeAtom/YoutubeAtomPlayer.tsx | 26 ++++-------- .../src/lib/useVideoMilestoneTracking.ts | 40 ++++++++++++++++--- 3 files changed, 47 insertions(+), 26 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index e66964e182c..59c84c5f741 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -544,7 +544,7 @@ export const SelfHostedVideo = ({ currentTime, }); - const trackMilestones = useVideoMilestoneTracking(sendOphanTrackingEvent); + const [trackMilestones] = useVideoMilestoneTracking(sendOphanTrackingEvent); const playVideo = useCallback(async () => { const video = vidRef.current; @@ -1084,7 +1084,10 @@ export const SelfHostedVideo = ({ * videos, not loops or cinemagraphs. */ if (videoStyle === 'Default') { - trackMilestones(video.currentTime, video.duration); + trackMilestones({ + currentTime: video.currentTime, + duration: video.duration, + }); } } }; diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx index cc9640bc4c9..1ab97bb11b9 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -47,7 +47,6 @@ type Props = { type ProgressEvents = { hasSentPlayEvent: boolean; - hasSentEndEvent: boolean; }; /** @@ -196,7 +195,7 @@ const createOnStateChangeListener = uniqueId: string, progressEvents: ProgressEvents, sendOphanTrackingEvent: (event: VideoEventKey) => void, - trackMilestones: (currentTime: number, duration: number) => void, + trackMilestones: ReturnType[0], ): YT.PlayerEventHandler => (event) => { const loggerFrom = 'YoutubeAtomPlayer onStateChange'; @@ -245,7 +244,10 @@ const createOnStateChangeListener = } const checkProgress = () => { - trackMilestones(player.getCurrentTime(), player.getDuration()); + trackMilestones({ + currentTime: player.getCurrentTime(), + duration: player.getDuration(), + }); if (player.getPlayerState() !== YT.PlayerState.ENDED) { /** @@ -281,20 +283,9 @@ const createOnStateChangeListener = 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; + trackMilestones({ ended: true }); progressEvents.hasSentPlayEvent = false; } }; @@ -441,11 +432,10 @@ export const YoutubeAtomPlayer = ({ }, [eventEmitters], ); - const trackMilestones = useVideoMilestoneTracking(sendOphanTrackingEvent); + const [trackMilestones] = useVideoMilestoneTracking(sendOphanTrackingEvent); const progressEvents = useRef({ hasSentPlayEvent: false, - hasSentEndEvent: false, }); const [playerReady, setPlayerReady] = useState(false); diff --git a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index 3639ce37945..4192f65fe45 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -5,6 +5,7 @@ type Milestones = { hasSent25: boolean; hasSent50: boolean; hasSent75: boolean; + hasSentEnd: boolean; }; /** @@ -14,18 +15,34 @@ type Milestones = { */ export const useVideoMilestoneTracking = ( onMilestone: (event: VideoEventKey) => void, -): ((currentTime: number, duration: number) => void) => { - const milestones = useRef({ +): [ + ( + progress: { currentTime: number; duration: number } | { ended: true }, + ) => void, + () => void, +] => { + const clearMilestones = () => ({ 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; + const trackMilestone = useCallback( + ( + progress: + | { currentTime: number; duration: number } + | { ended: true }, + ) => { + let percent = 0; + + if ('ended' in progress) { + percent = 100; + } else if ('duration' in progress && progress.duration > 0) { + percent = (progress.currentTime / progress.duration) * 100; + } if (!milestones.current.hasSent25 && percent >= 25) { onMilestone('25'); @@ -41,7 +58,18 @@ export const useVideoMilestoneTracking = ( onMilestone('75'); milestones.current.hasSent75 = true; } + + if (!milestones.current.hasSentEnd && percent >= 100) { + onMilestone('end'); + milestones.current.hasSentEnd = true; + } }, [onMilestone], ); + + const resetMilestones = () => { + milestones.current = clearMilestones(); + }; + + return [trackMilestone, resetMilestones]; }; From 579d071da7c89cdd8d883897f534f5d33704d3e3 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 14:35:59 +0100 Subject: [PATCH 2/8] Add play milestone tracking to hook --- .../src/lib/useVideoMilestoneTracking.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index 4192f65fe45..60c4b7d95f4 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'; import type { VideoEventKey } from '../components/YoutubeAtom/YoutubeAtom'; type Milestones = { + hasSentPlay: boolean; hasSent25: boolean; hasSent50: boolean; hasSent75: boolean; @@ -17,11 +18,15 @@ export const useVideoMilestoneTracking = ( onMilestone: (event: VideoEventKey) => void, ): [ ( - progress: { currentTime: number; duration: number } | { ended: true }, + progress: + | { currentTime: number; duration: number } + | { started: true } + | { ended: true }, ) => void, () => void, ] => { const clearMilestones = () => ({ + hasSentPlay: false, hasSent25: false, hasSent50: false, hasSent75: false, @@ -34,6 +39,7 @@ export const useVideoMilestoneTracking = ( ( progress: | { currentTime: number; duration: number } + | { started: true } | { ended: true }, ) => { let percent = 0; @@ -44,6 +50,11 @@ export const useVideoMilestoneTracking = ( percent = (progress.currentTime / progress.duration) * 100; } + if (!milestones.current.hasSentPlay && percent >= 0) { + onMilestone('play'); + milestones.current.hasSentPlay = true; + } + if (!milestones.current.hasSent25 && percent >= 25) { onMilestone('25'); milestones.current.hasSent25 = true; From 2354f29087e236431f038811c7957a4bd902ca82 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 14:36:38 +0100 Subject: [PATCH 3/8] Implement play milestone tracking using hook in YouTubeAtomPlayer --- .../YoutubeAtom/YoutubeAtomPlayer.tsx | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx index 1ab97bb11b9..dc3fd3d1c80 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -45,10 +45,6 @@ type Props = { renderingTarget: RenderingTarget; }; -type ProgressEvents = { - hasSentPlayEvent: boolean; -}; - /** * Player listeners e.g. * name: onReady, onStateChange, etc... @@ -193,9 +189,10 @@ const createOnStateChangeListener = ( videoId: string, uniqueId: string, - progressEvents: ProgressEvents, + playerState: { paused: boolean }, sendOphanTrackingEvent: (event: VideoEventKey) => void, trackMilestones: ReturnType[0], + resetMilestones: () => void, ): YT.PlayerEventHandler => (event) => { const loggerFrom = 'YoutubeAtomPlayer onStateChange'; @@ -217,32 +214,20 @@ 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'); } + playerState.paused = false; + + /** + * Set a timeout to check progress in the future + */ + setTimeout(() => { + checkProgress(); + }, 3000); + const checkProgress = () => { trackMilestones({ currentTime: player.getCurrentTime(), @@ -270,6 +255,7 @@ const createOnStateChangeListener = event, }); sendOphanTrackingEvent('pause'); + playerState.paused = true; } if (event.data === YT.PlayerState.CUED) { @@ -280,13 +266,12 @@ const createOnStateChangeListener = event, }); sendOphanTrackingEvent('cued'); - progressEvents.hasSentPlayEvent = false; } if (event.data === YT.PlayerState.ENDED) { dispatchCustomPauseEvent(uniqueId); trackMilestones({ ended: true }); - progressEvents.hasSentPlayEvent = false; + resetMilestones(); } }; @@ -432,11 +417,12 @@ export const YoutubeAtomPlayer = ({ }, [eventEmitters], ); - const [trackMilestones] = useVideoMilestoneTracking(sendOphanTrackingEvent); - - const progressEvents = useRef({ - hasSentPlayEvent: false, - }); + const [trackMilestones, resetMilestones] = useVideoMilestoneTracking( + sendOphanTrackingEvent, + ); + const playerPauseState = useRef<{ + paused: boolean; + }>({ paused: false }); const [playerReady, setPlayerReady] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); @@ -475,9 +461,10 @@ export const YoutubeAtomPlayer = ({ const onStateChangeListener = createOnStateChangeListener( videoId, uniqueId, - progressEvents.current, + playerPauseState.current, sendOphanTrackingEvent, trackMilestones, + resetMilestones, ); /** From 2f7f3e88aa7b30315fe0a14c7cb424e84a6efb52 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 15:06:16 +0100 Subject: [PATCH 4/8] Implement play and end milestone tracking into self hosted video player --- .../src/components/SelfHostedVideo.island.tsx | 32 +++++++++---------- .../src/components/SelfHostedVideoPlayer.tsx | 4 +++ .../src/lib/useVideoMilestoneTracking.ts | 2 +- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 59c84c5f741..c28a14c4eaa 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,9 @@ export const SelfHostedVideo = ({ currentTime, }); - const [trackMilestones] = useVideoMilestoneTracking(sendOphanTrackingEvent); + const [trackMilestones, resetMilestones] = useVideoMilestoneTracking( + sendOphanTrackingEvent, + ); const playVideo = useCallback(async () => { const video = vidRef.current; @@ -608,10 +609,8 @@ export const SelfHostedVideo = ({ } } else { void playVideo(); - if (hasTrackedPlay) { + if (playerState !== 'ENDED') { sendOphanTrackingEvent('resume'); - } else { - sendOphanTrackingEvent('play'); } } }; @@ -937,11 +936,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 +1024,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. @@ -1083,12 +1084,10 @@ export const SelfHostedVideo = ({ * We only want to track milestone events for "long-form" * videos, not loops or cinemagraphs. */ - if (videoStyle === 'Default') { - trackMilestones({ - currentTime: video.currentTime, - duration: video.duration, - }); - } + trackMilestones({ + currentTime: video.currentTime, + duration: video.duration, + }); } }; @@ -1209,6 +1208,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/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index 60c4b7d95f4..0dd6591abe9 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -70,7 +70,7 @@ export const useVideoMilestoneTracking = ( milestones.current.hasSent75 = true; } - if (!milestones.current.hasSentEnd && percent >= 100) { + if (!milestones.current.hasSentEnd && percent >= 99) { onMilestone('end'); milestones.current.hasSentEnd = true; } From 84ea61bb4d475487224e3ce3dd19f8888ed44ef9 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 15:27:20 +0100 Subject: [PATCH 5/8] Reset video tracking milestones for looping video --- .../src/components/SelfHostedVideo.island.tsx | 5 ++++- .../src/lib/useVideoMilestoneTracking.ts | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index c28a14c4eaa..9859b2f331c 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -1079,7 +1079,6 @@ export const SelfHostedVideo = ({ if (playerState === 'PLAYING') { setCurrentTime(video.currentTime); - /** * We only want to track milestone events for "long-form" * videos, not loops or cinemagraphs. @@ -1088,6 +1087,10 @@ export const SelfHostedVideo = ({ currentTime: video.currentTime, duration: video.duration, }); + + if (video.currentTime < 1) { + resetMilestones(); + } } }; diff --git a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index 0dd6591abe9..7d89c5d5177 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -11,8 +11,8 @@ type Milestones = { /** * 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. + * It tracks when a video begins, ends or crosses the 25%, 50% and 75% + * milestones. On each milestone the provided callback is triggered. */ export const useVideoMilestoneTracking = ( onMilestone: (event: VideoEventKey) => void, @@ -35,6 +35,12 @@ export const useVideoMilestoneTracking = ( const milestones = useRef(clearMilestones()); + /** + * 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: @@ -78,8 +84,14 @@ export const useVideoMilestoneTracking = ( [onMilestone], ); + /** + * Resets the milestones, allowing further tracking events to be triggered. + * Will not reset if the video has not reached the end. + */ const resetMilestones = () => { - milestones.current = clearMilestones(); + if (milestones.current.hasSentEnd) { + milestones.current = clearMilestones(); + } }; return [trackMilestone, resetMilestones]; From fcfde92fb6c1331665a93828804255e64c9479fa Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 15:31:28 +0100 Subject: [PATCH 6/8] Only tracks percentage milestones for default video --- .../src/components/SelfHostedVideo.island.tsx | 1 + .../YoutubeAtom/YoutubeAtomPlayer.tsx | 1 + .../src/lib/useVideoMilestoneTracking.ts | 27 ++++++++++--------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 9859b2f331c..5f85edefeb6 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -545,6 +545,7 @@ export const SelfHostedVideo = ({ const [trackMilestones, resetMilestones] = useVideoMilestoneTracking( sendOphanTrackingEvent, + videoStyle === 'Default', ); const playVideo = useCallback(async () => { diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx index dc3fd3d1c80..8f6a2ad3071 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -625,6 +625,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 7d89c5d5177..b1aa18dc9e6 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -16,6 +16,7 @@ type Milestones = { */ export const useVideoMilestoneTracking = ( onMilestone: (event: VideoEventKey) => void, + trackPercentageMilestones: boolean = true, ): [ ( progress: @@ -61,19 +62,21 @@ export const useVideoMilestoneTracking = ( milestones.current.hasSentPlay = true; } - if (!milestones.current.hasSent25 && percent >= 25) { - onMilestone('25'); - milestones.current.hasSent25 = 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.hasSent50 && percent >= 50) { + onMilestone('50'); + milestones.current.hasSent50 = true; + } - if (!milestones.current.hasSent75 && percent >= 75) { - onMilestone('75'); - milestones.current.hasSent75 = true; + if (!milestones.current.hasSent75 && percent >= 75) { + onMilestone('75'); + milestones.current.hasSent75 = true; + } } if (!milestones.current.hasSentEnd && percent >= 99) { @@ -81,7 +84,7 @@ export const useVideoMilestoneTracking = ( milestones.current.hasSentEnd = true; } }, - [onMilestone], + [onMilestone, trackPercentageMilestones], ); /** From b6fa01dc20b94f55e5aad6c80630d0ac36ac5137 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 16:10:00 +0100 Subject: [PATCH 7/8] Prevents RESUME events from being fired if autoplay is not used on self-hosted video --- dotcom-rendering/src/components/SelfHostedVideo.island.tsx | 2 +- dotcom-rendering/src/lib/useVideoMilestoneTracking.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index 5f85edefeb6..5d96510eb4e 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -610,7 +610,7 @@ export const SelfHostedVideo = ({ } } else { void playVideo(); - if (playerState !== 'ENDED') { + if (playerState !== 'NOT_STARTED' && playerState !== 'ENDED') { sendOphanTrackingEvent('resume'); } } diff --git a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts index b1aa18dc9e6..6c8daf85ac1 100644 --- a/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts +++ b/dotcom-rendering/src/lib/useVideoMilestoneTracking.ts @@ -10,9 +10,11 @@ type Milestones = { }; /** - * Returns a function that should be called on each time update. + * 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, From 5fb7e3b74d518a37b9ac9605fc60588ad774b1a1 Mon Sep 17 00:00:00 2001 From: Sam Hession Date: Wed, 20 May 2026 16:27:25 +0100 Subject: [PATCH 8/8] Prevents multiple milestone checking timeout chains --- .../YoutubeAtom/YoutubeAtomPlayer.tsx | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx index 8f6a2ad3071..640036a350e 100644 --- a/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx +++ b/dotcom-rendering/src/components/YoutubeAtom/YoutubeAtomPlayer.tsx @@ -189,7 +189,10 @@ const createOnStateChangeListener = ( videoId: string, uniqueId: string, - playerState: { paused: boolean }, + playerState: { + paused: boolean; + progressIntervalId: ReturnType | undefined; + }, sendOphanTrackingEvent: (event: VideoEventKey) => void, trackMilestones: ReturnType[0], resetMilestones: () => void, @@ -221,28 +224,17 @@ const createOnStateChangeListener = playerState.paused = false; - /** - * Set a timeout to check progress in the future - */ - setTimeout(() => { - checkProgress(); - }, 3000); - - const checkProgress = () => { + clearInterval(playerState.progressIntervalId); + playerState.progressIntervalId = setInterval(() => { trackMilestones({ currentTime: player.getCurrentTime(), duration: player.getDuration(), }); - if (player.getPlayerState() !== YT.PlayerState.ENDED) { - /** - * Set a timeout to check progress again in the future - */ - setTimeout(() => checkProgress(), 3000); + if (player.getPlayerState() === YT.PlayerState.ENDED) { + clearInterval(playerState.progressIntervalId); } - - return null; - }; + }, 3000); } if (event.data === YT.PlayerState.PAUSED) { @@ -256,6 +248,7 @@ const createOnStateChangeListener = }); sendOphanTrackingEvent('pause'); playerState.paused = true; + clearInterval(playerState.progressIntervalId); } if (event.data === YT.PlayerState.CUED) { @@ -270,6 +263,7 @@ const createOnStateChangeListener = if (event.data === YT.PlayerState.ENDED) { dispatchCustomPauseEvent(uniqueId); + clearInterval(playerState.progressIntervalId); trackMilestones({ ended: true }); resetMilestones(); } @@ -422,7 +416,8 @@ export const YoutubeAtomPlayer = ({ ); const playerPauseState = useRef<{ paused: boolean; - }>({ paused: false }); + progressIntervalId: ReturnType | undefined; + }>({ paused: false, progressIntervalId: undefined }); const [playerReady, setPlayerReady] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);