From 030273128f7c05c8bf7dae85e361c10a01e265ed Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 20 Apr 2026 15:51:43 +0100 Subject: [PATCH 01/20] Add new interface to track video progress --- src/app/components/content/IsaacVideo.tsx | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 639987901e..ad82d06547 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -10,10 +10,14 @@ interface IsaacVideoProps { } interface VideoEventDetails { - type: "VIDEO_PLAY" | "VIDEO_PAUSE" | "VIDEO_ENDED"; + type: "VIDEO_PLAY" | "VIDEO_PAUSE" | "VIDEO_ENDED" | "VIDEO_60_PERCENT_WATCHED"; + videoId?: string; videoUrl: string; + videoDurationSeconds?: number; videoPosition?: number; pageId?: string; + watchedSeconds?: number; + watchPercent?: number; } interface WistiaPostMessageData { @@ -81,6 +85,23 @@ const VIDEO_PLATFORMS = { }, } as const; +const VIDEO_WATCH_THRESHOLD = 0.6; +const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; + +interface WatchedSegment { +watchedSegmentStart: number; +watchedSegmentEnd: number; +} + +interface VideoProgressState { + totalVideoDurationInSeconds: number | null; + segments: WatchedSegment[]; + currentSegmentStart: number | null; + lastKnownTime: number | null; + isPlaying: boolean; + thresholdLogged: boolean; +} + /** * Check if a URL hostname matches allowed hosts for a platform */ @@ -151,6 +172,8 @@ function extractVideoId(embedSrc: string, pattern: RegExp): string | null { return match ? match[1] : null; } + + /** * Log video events to the backend */ From 735e4286f7e9767b88d61628f52f00b393194fc0 Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 23 Apr 2026 10:56:36 +0100 Subject: [PATCH 02/20] Add a clamp function to avoid unwanted video progress value --- src/app/components/content/IsaacVideo.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index ad82d06547..04b657adc8 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -87,6 +87,7 @@ const VIDEO_PLATFORMS = { const VIDEO_WATCH_THRESHOLD = 0.6; const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; +const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; interface WatchedSegment { watchedSegmentStart: number; @@ -172,7 +173,18 @@ function extractVideoId(embedSrc: string, pattern: RegExp): string | null { return match ? match[1] : null; } +function getVideoProgressStorageKey(videoId: string): string { + return `${VIDEO_PROGRESS_STORAGE_PREFIX}${videoId}`; +} + +function isValidNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} +//clamp function is to ensure tat the value of video segments is between 0 and total video duration +function clampVideoProgressValue(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} /** * Log video events to the backend From a4123c891dc5e481918b32dd0291d70beb0c839e Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 23 Apr 2026 14:29:44 +0100 Subject: [PATCH 03/20] Add logic for merge segments --- src/app/components/content/IsaacVideo.tsx | 29 +++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 04b657adc8..7126a514b9 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -90,8 +90,8 @@ const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; interface WatchedSegment { -watchedSegmentStart: number; -watchedSegmentEnd: number; + watchedSegmentStart: number; + watchedSegmentEnd: number; } interface VideoProgressState { @@ -186,6 +186,31 @@ function clampVideoProgressValue(value: number, min: number, max: number): numbe return Math.max(min, Math.min(value, max)); } +function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { + if (segments.length === 0) return []; + + const sortedSegments = [...segments].sort((a, b) => a.watchedSegmentStart - b.watchedSegmentStart); + + // Initialize merged segments with the first segment + const mergedSegments: WatchedSegment[] = [{ ...sortedSegments[0] }]; + // Iterate through sorted segments from the second one onwards + for (let i = 1; i < sortedSegments.length; i++) { + const currentSegment = sortedSegments[i]; + const lastMergedSegment = mergedSegments[mergedSegments.length - 1]; + + if (currentSegment.watchedSegmentStart <= lastMergedSegment.watchedSegmentEnd + 0.5) { + lastMergedSegment.watchedSegmentEnd = Math.max( + lastMergedSegment.watchedSegmentEnd, + currentSegment.watchedSegmentEnd, + ); + } else { + mergedSegments.push({ ...currentSegment }); + } + } + + return mergedSegments; +} + /** * Log video events to the backend */ From 06d83adb88ad29055d9ec0526fe5133028a54b92 Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 27 Apr 2026 10:42:53 +0100 Subject: [PATCH 04/20] Add logic for calculating unique watched seconds and watch percent --- src/app/components/content/IsaacVideo.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 7126a514b9..e02ed72784 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -207,10 +207,21 @@ function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { mergedSegments.push({ ...currentSegment }); } } - return mergedSegments; } +function getUniqueWatchedSeconds(segments: WatchedSegment[]): number { + return segments.reduce( + (total, segment) => total + Math.max(0, segment.watchedSegmentEnd - segment.watchedSegmentStart), + 0, + ); +} + +function getWatchPercent(uniqueWatchedSeconds: number, totalVideoDurationInSeconds: number): number { + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return 0; + return uniqueWatchedSeconds / totalVideoDurationInSeconds; +} + /** * Log video events to the backend */ From 235ccca9140c3e0e99a3f1ea5881f17b42882f76 Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 29 Apr 2026 11:51:30 +0100 Subject: [PATCH 05/20] Add logic for retrieving video progress from local storage --- src/app/components/content/IsaacVideo.tsx | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index e02ed72784..d53bfe1a10 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -20,6 +20,12 @@ interface VideoEventDetails { watchPercent?: number; } +interface VideoProgressStore { + durationSeconds: number | null; + segments: WatchedSegment[]; + thresholdLogged: boolean; +} + interface WistiaPostMessageData { method: string; args: Array>; @@ -222,6 +228,31 @@ function getWatchPercent(uniqueWatchedSeconds: number, totalVideoDurationInSecon return uniqueWatchedSeconds / totalVideoDurationInSeconds; } +function loadVideoProgress(videoId: string): VideoProgressStore | null { + try { + const localStorageVideoData = globalThis.localStorage?.getItem(getVideoProgressStorageKey(videoId)); + if (!localStorageVideoData) return null; + const parsed = JSON.parse(localStorageVideoData) as Partial; + const durationSeconds = + isValidNumber(parsed.durationSeconds) && parsed.durationSeconds > 0 ? parsed.durationSeconds : null; + const segments = Array.isArray(parsed.segments) + ? mergeSegments( + parsed.segments + .filter( + (s): s is WatchedSegment => + isValidNumber((s as WatchedSegment).watchedSegmentStart) && + isValidNumber((s as WatchedSegment).watchedSegmentEnd), + ) + .map((s) => ({ watchedSegmentStart: s.watchedSegmentStart, watchedSegmentEnd: s.watchedSegmentEnd })), + ) + : []; + const thresholdLogged = parsed.thresholdLogged === true; + return { durationSeconds, segments, thresholdLogged }; + } catch { + return null; + } +} + /** * Log video events to the backend */ From 6e9d6b20a70e7d4975e83e5b68b64b4bf015bd73 Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 30 Apr 2026 16:26:33 +0100 Subject: [PATCH 06/20] Add logic for catching the initial progress state of the video --- src/app/components/content/IsaacVideo.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index d53bfe1a10..5726612afe 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -253,6 +253,29 @@ function loadVideoProgress(videoId: string): VideoProgressStore | null { } } +function createInitialVideoProgressState(videoId: string | null): VideoProgressState { + if (!videoId) { + return { + totalVideoDurationInSeconds: null, + segments: [], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: false, + }; + } + + const stored = loadVideoProgress(videoId); + return { + totalVideoDurationInSeconds: stored?.durationSeconds ?? null, + segments: stored?.segments ?? [], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: stored?.thresholdLogged ?? false, + }; +} + /** * Log video events to the backend */ From fe0f975e804986ba6a498a29244dc5e8feca279b Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 1 May 2026 11:19:03 +0100 Subject: [PATCH 07/20] Add logic for storing the video progress in the local storage --- src/app/components/content/IsaacVideo.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 5726612afe..7bdb97ffb6 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -276,6 +276,19 @@ function createInitialVideoProgressState(videoId: string | null): VideoProgressS }; } +function saveVideoProgress(videoId: string, state: VideoProgressState): void { + try { + const toStore: VideoProgressStore = { + durationSeconds: state.totalVideoDurationInSeconds, + segments: state.segments, + thresholdLogged: state.thresholdLogged, + }; + globalThis.localStorage?.setItem(getVideoProgressStorageKey(videoId), JSON.stringify(toStore)); + } catch { + // ignore localStorage failures + } +} + /** * Log video events to the backend */ From 95e898c5212557841c5417ea91df6ae4815c6e85 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 1 May 2026 11:22:46 +0100 Subject: [PATCH 08/20] rename duration field --- src/app/components/content/IsaacVideo.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 7bdb97ffb6..131fb59595 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -21,7 +21,7 @@ interface VideoEventDetails { } interface VideoProgressStore { - durationSeconds: number | null; + totalVideoDurationInSeconds: number | null; segments: WatchedSegment[]; thresholdLogged: boolean; } @@ -233,8 +233,10 @@ function loadVideoProgress(videoId: string): VideoProgressStore | null { const localStorageVideoData = globalThis.localStorage?.getItem(getVideoProgressStorageKey(videoId)); if (!localStorageVideoData) return null; const parsed = JSON.parse(localStorageVideoData) as Partial; - const durationSeconds = - isValidNumber(parsed.durationSeconds) && parsed.durationSeconds > 0 ? parsed.durationSeconds : null; + const totalVideoDurationInSeconds = + isValidNumber(parsed.totalVideoDurationInSeconds) && parsed.totalVideoDurationInSeconds > 0 + ? parsed.totalVideoDurationInSeconds + : null; const segments = Array.isArray(parsed.segments) ? mergeSegments( parsed.segments @@ -247,7 +249,7 @@ function loadVideoProgress(videoId: string): VideoProgressStore | null { ) : []; const thresholdLogged = parsed.thresholdLogged === true; - return { durationSeconds, segments, thresholdLogged }; + return { totalVideoDurationInSeconds, segments, thresholdLogged }; } catch { return null; } @@ -267,7 +269,7 @@ function createInitialVideoProgressState(videoId: string | null): VideoProgressS const stored = loadVideoProgress(videoId); return { - totalVideoDurationInSeconds: stored?.durationSeconds ?? null, + totalVideoDurationInSeconds: stored?.totalVideoDurationInSeconds ?? null, segments: stored?.segments ?? [], currentSegmentStart: null, lastKnownTime: null, @@ -279,7 +281,7 @@ function createInitialVideoProgressState(videoId: string | null): VideoProgressS function saveVideoProgress(videoId: string, state: VideoProgressState): void { try { const toStore: VideoProgressStore = { - durationSeconds: state.totalVideoDurationInSeconds, + totalVideoDurationInSeconds: state.totalVideoDurationInSeconds, segments: state.segments, thresholdLogged: state.thresholdLogged, }; From b7cf592bb488da3ffb90871653ce84e743d87033 Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 1 May 2026 16:09:55 +0100 Subject: [PATCH 09/20] Make videoId mandatory for logging video progress --- src/app/components/content/IsaacVideo.tsx | 46 ++++++++++++++--------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 131fb59595..3f8438fc4b 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -11,7 +11,7 @@ interface IsaacVideoProps { interface VideoEventDetails { type: "VIDEO_PLAY" | "VIDEO_PAUSE" | "VIDEO_ENDED" | "VIDEO_60_PERCENT_WATCHED"; - videoId?: string; + videoId: string; videoUrl: string; videoDurationSeconds?: number; videoPosition?: number; @@ -317,16 +317,30 @@ async function logVideoEvent( function createEventDetails( type: VideoEventDetails["type"], videoUrl: string, - pageId?: string, - videoPosition?: number, + videoId: string, + options?: { + pageId?: string; + videoPosition?: number; + videoDurationSeconds?: number; + watchedSeconds?: number; + watchPercent?: number; + }, ): VideoEventDetails { - const details: VideoEventDetails = { type, videoUrl }; - if (pageId) details.pageId = pageId; - if (videoPosition !== undefined) details.videoPosition = videoPosition; + const details: VideoEventDetails = { type, videoUrl, videoId }; + if (options?.pageId) details.pageId = options.pageId; + if (options?.videoPosition !== undefined) details.videoPosition = options.videoPosition; + if (options?.videoDurationSeconds !== undefined) details.videoDurationSeconds = options.videoDurationSeconds; + if (options?.watchedSeconds !== undefined) details.watchedSeconds = options.watchedSeconds; + if (options?.watchPercent !== undefined) details.watchPercent = options.watchPercent; return details; } -function onPlayerStateChange(event: YouTubeEvent, pageId?: string, dispatch?: ReturnType): void { +function onPlayerStateChange( + event: YouTubeEvent, + videoId: string, + pageId?: string, + dispatch?: ReturnType, +): void { const YT = globalThis.YT; if (!YT) return; @@ -348,12 +362,10 @@ function onPlayerStateChange(event: YouTubeEvent, pageId?: string, dispatch?: Re return; } - const eventDetails = createEventDetails( - eventType, - videoUrl, + const eventDetails = createEventDetails(eventType, videoUrl, videoId, { pageId, - eventType === "VIDEO_ENDED" ? undefined : videoPosition, - ); + videoPosition: eventType === "VIDEO_ENDED" ? undefined : videoPosition, + }); logVideoEvent(eventDetails, dispatch); } @@ -451,12 +463,10 @@ export function IsaacVideo(props: IsaacVideoProps) { const eventType = eventTypeMap[eventName.toLowerCase()]; if (!eventType) return; - const eventDetails = createEventDetails( - eventType, - embedSrc || "", + const eventDetails = createEventDetails(eventType, embedSrc || "", wistiaVideoId, { pageId, - eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, - ); + videoPosition: eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, + }); logVideoEvent(eventDetails, dispatch); }; @@ -540,7 +550,7 @@ export function IsaacVideo(props: IsaacVideoProps) { origin: globalThis.location.origin, }, events: { - onStateChange: (event: YouTubeEvent) => onPlayerStateChange(event, pageId, dispatch), + onStateChange: (event: YouTubeEvent) => onPlayerStateChange(event, youtubeVideoId, pageId, dispatch), }, }); }); From 65dc1d618e18acc764a39c64aeab549e07c3442a Mon Sep 17 00:00:00 2001 From: Madhura Date: Tue, 5 May 2026 11:41:05 +0100 Subject: [PATCH 10/20] Add logic for video progress tracking depending on canonical video id --- src/app/components/content/IsaacVideo.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 3f8438fc4b..b2716e23e8 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -403,6 +403,13 @@ export function IsaacVideo(props: IsaacVideoProps) { [isYouTube, embedSrc], ); + const canonicalVideoId = youtubeVideoId || wistiaVideoId; + const progressReference = useRef(createInitialVideoProgressState(canonicalVideoId)); + + React.useEffect(() => { + progressReference.current = createInitialVideoProgressState(canonicalVideoId); + }, [canonicalVideoId]); + // Load Wistia API script React.useEffect(() => { if (!isWistia || globalThis.Wistia) return; From bf37c326e6a6c28cf5f1279fe700b029990da534 Mon Sep 17 00:00:00 2001 From: Madhura Date: Tue, 5 May 2026 13:43:48 +0100 Subject: [PATCH 11/20] Bring together storing video and checking if video threshold reached --- src/app/components/content/IsaacVideo.tsx | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index b2716e23e8..4571770065 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -410,6 +410,41 @@ export function IsaacVideo(props: IsaacVideoProps) { progressReference.current = createInitialVideoProgressState(canonicalVideoId); }, [canonicalVideoId]); + const setTotalVideoDurationIfPresent = useCallback( + (totalVideoDurationInSeconds: number | null | undefined) => { + if (!canonicalVideoId || !isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + if (progressReference.current.totalVideoDurationInSeconds === totalVideoDurationInSeconds) return; + progressReference.current.totalVideoDurationInSeconds = totalVideoDurationInSeconds; + saveVideoProgress(canonicalVideoId, progressReference.current); + }, + [canonicalVideoId], + ); + + const checkIf60PercentWatchedAndLog = useCallback( + (videoUrl: string) => { + if (!canonicalVideoId || progressReference.current.thresholdLogged) return; + const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + + const uniqueWatchedSeconds = getUniqueWatchedSeconds(progressReference.current.segments); + const watchPercent = getWatchPercent(uniqueWatchedSeconds, totalVideoDurationInSeconds); + if (watchPercent < VIDEO_WATCH_THRESHOLD) return; + + progressReference.current.thresholdLogged = true; + saveVideoProgress(canonicalVideoId, progressReference.current); + + const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, { + pageId, + videoId: canonicalVideoId, + videoDurationSeconds: totalVideoDurationInSeconds, + watchedSeconds, + watchPercent, + }); + logVideoEvent(eventDetails, dispatch); + }, + [canonicalVideoId, dispatch, pageId], + ); + // Load Wistia API script React.useEffect(() => { if (!isWistia || globalThis.Wistia) return; From 126210f1874c51a730d3dae901dd78b748d05ca5 Mon Sep 17 00:00:00 2001 From: Madhura Date: Tue, 5 May 2026 15:49:37 +0100 Subject: [PATCH 12/20] Add logic for starting and closing segments --- src/app/components/content/IsaacVideo.tsx | 69 +++++++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 4571770065..459823cf28 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -421,7 +421,7 @@ export function IsaacVideo(props: IsaacVideoProps) { ); const checkIf60PercentWatchedAndLog = useCallback( - (videoUrl: string) => { + (videoId: string, videoUrl: string) => { if (!canonicalVideoId || progressReference.current.thresholdLogged) return; const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; @@ -433,11 +433,10 @@ export function IsaacVideo(props: IsaacVideoProps) { progressReference.current.thresholdLogged = true; saveVideoProgress(canonicalVideoId, progressReference.current); - const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, { + const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, videoId, { pageId, - videoId: canonicalVideoId, videoDurationSeconds: totalVideoDurationInSeconds, - watchedSeconds, + watchedSeconds: uniqueWatchedSeconds, watchPercent, }); logVideoEvent(eventDetails, dispatch); @@ -445,6 +444,42 @@ export function IsaacVideo(props: IsaacVideoProps) { [canonicalVideoId, dispatch, pageId], ); + const appendSegment = useCallback( + (segmentStart: number, segmentEnd: number, videoId: string, videoUrl: string) => { + const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + + const clampedStart = clampVideoProgressValue(segmentStart, 0, totalVideoDurationInSeconds); + const clampedEnd = clampVideoProgressValue(segmentEnd, 0, totalVideoDurationInSeconds); + if (clampedEnd - clampedStart < 0.5) return; + + progressReference.current.segments = mergeSegments([ + ...progressReference.current.segments, + { watchedSegmentStart: clampedStart, watchedSegmentEnd: clampedEnd }, + ]); + if (canonicalVideoId) { + saveVideoProgress(canonicalVideoId, progressReference.current); + } + checkIf60PercentWatchedAndLog(videoId, videoUrl); + }, + [canonicalVideoId, checkIf60PercentWatchedAndLog], + ); + + const startCurrentSegment = useCallback((segmentStart: number) => { + progressReference.current.currentSegmentStart = segmentStart; + progressReference.current.lastKnownTime = segmentStart; + }, []); + + const closeCurrentSegment = useCallback( + (segmentEnd: number, videoUrl: string, videoId: string) => { + const currentSegmentStart = progressReference.current.currentSegmentStart; + if (!isValidNumber(currentSegmentStart)) return; + appendSegment(currentSegmentStart, segmentEnd, videoId, videoUrl); + progressReference.current.currentSegmentStart = null; + }, + [appendSegment], + ); + // Load Wistia API script React.useEffect(() => { if (!isWistia || globalThis.Wistia) return; @@ -502,7 +537,29 @@ export function IsaacVideo(props: IsaacVideoProps) { const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { updateTimeFromEventData(eventData); - const eventType = eventTypeMap[eventName.toLowerCase()]; + const durationFromEventData = typeof eventData.duration === "number" ? eventData.duration : undefined; + setTotalVideoDurationIfPresent(durationFromEventData); + + const lowerCaseEventName = eventName.toLowerCase(); + if (lowerCaseEventName === "play" || lowerCaseEventName === "playing") { + if (!progressReference.current.isPlaying) { + progressReference.current.isPlaying = true; + progressReference.current.currentSegmentStart = lastKnownTime; + } + } else if ( + lowerCaseEventName === "pause" || + lowerCaseEventName === "paused" || + lowerCaseEventName === "end" || + lowerCaseEventName === "ended" + ) { + if (isValidNumber(progressReference.current.currentSegmentStart)) { + appendSegment(progressReference.current.currentSegmentStart, lastKnownTime, wistiaVideoId, embedSrc || ""); + } + progressReference.current.currentSegmentStart = null; + progressReference.current.isPlaying = false; + } + + const eventType = eventTypeMap[lowerCaseEventName]; if (!eventType) return; const eventDetails = createEventDetails(eventType, embedSrc || "", wistiaVideoId, { @@ -572,7 +629,7 @@ export function IsaacVideo(props: IsaacVideoProps) { globalThis.removeEventListener("message", handleWistiaMessage); clearTimeout(timer); }; - }, [isWistia, wistiaVideoId, embedSrc, pageId, dispatch]); + }, [isWistia, wistiaVideoId, embedSrc, pageId, dispatch, appendSegment, setTotalVideoDurationIfPresent]); // YouTube video initialization const youtubeRef = useCallback( From cdde197fb6a863cec214dfe4a04e5f16da640450 Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 6 May 2026 11:18:17 +0100 Subject: [PATCH 13/20] Add function for updating video playback progress --- src/app/components/content/IsaacVideo.tsx | 65 +++++++++++++---------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 459823cf28..fc85532945 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -467,7 +467,7 @@ export function IsaacVideo(props: IsaacVideoProps) { const startCurrentSegment = useCallback((segmentStart: number) => { progressReference.current.currentSegmentStart = segmentStart; - progressReference.current.lastKnownTime = segmentStart; + progressReference.current.lastKnownTime = segmentStart; // this will set a baseline time when a new segment starts, so that we can detect seeks }, []); const closeCurrentSegment = useCallback( @@ -480,6 +480,26 @@ export function IsaacVideo(props: IsaacVideoProps) { [appendSegment], ); + // this function is used to update the playback progress of the video, and detect seeks by comparing the current time with the last known time. + const updatePlaybackProgress = useCallback( + (currentTime: number, videoUrl: string, videoId: string) => { + if (!isValidNumber(currentTime)) return; + const lastKnownTime = progressReference.current.lastKnownTime; + if (!progressReference.current.isPlaying || !isValidNumber(lastKnownTime)) { + progressReference.current.lastKnownTime = currentTime; + return; + } + + const diff = currentTime - lastKnownTime; + const isSeek = Math.abs(diff) > SEEK_DETECTION_TOLERANCE_SECONDS; + if (isSeek) { + closeCurrentSegment(lastKnownTime, videoUrl, videoId); + startCurrentSegment(currentTime); + } + }, + [closeCurrentSegment, startCurrentSegment], + ); + // Load Wistia API script React.useEffect(() => { if (!isWistia || globalThis.Wistia) return; @@ -535,39 +555,26 @@ export function IsaacVideo(props: IsaacVideoProps) { }; const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { - updateTimeFromEventData(eventData); - - const durationFromEventData = typeof eventData.duration === "number" ? eventData.duration : undefined; - setTotalVideoDurationIfPresent(durationFromEventData); - - const lowerCaseEventName = eventName.toLowerCase(); - if (lowerCaseEventName === "play" || lowerCaseEventName === "playing") { - if (!progressReference.current.isPlaying) { - progressReference.current.isPlaying = true; - progressReference.current.currentSegmentStart = lastKnownTime; - } - } else if ( - lowerCaseEventName === "pause" || - lowerCaseEventName === "paused" || - lowerCaseEventName === "end" || - lowerCaseEventName === "ended" - ) { - if (isValidNumber(progressReference.current.currentSegmentStart)) { - appendSegment(progressReference.current.currentSegmentStart, lastKnownTime, wistiaVideoId, embedSrc || ""); - } - progressReference.current.currentSegmentStart = null; - progressReference.current.isPlaying = false; + const videoUrl = embedSrc || ""; + const eventTime = getTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; + const totalVideoDurationInSeconds = getTotalVideoDurationInSecondsFromEventData(eventData); + if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { + setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); } - const eventType = eventTypeMap[lowerCaseEventName]; + const eventType = eventTypeMap[eventName.toLowerCase()]; if (!eventType) return; - const eventDetails = createEventDetails(eventType, embedSrc || "", wistiaVideoId, { - pageId, - videoPosition: eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, - }); + if (eventType === "VIDEO_PLAY") { + progressReference.current.isPlaying = true; + startCurrentSegment(eventTime); + } else { + progressReference.current.isPlaying = false; + closeCurrentSegment(eventTime, videoUrl); + progressRef.current.lastKnownTime = eventTime; + } - logVideoEvent(eventDetails, dispatch); + logPlayerEvent(eventType, videoUrl, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; const isTimeChangeEvent = (eventName: string): boolean => { From 04d005e72b468b0b4c69925b223981e60aa1185f Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 7 May 2026 16:31:24 +0100 Subject: [PATCH 14/20] Log wistia player events for running video --- src/app/components/content/IsaacVideo.tsx | 54 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index fc85532945..d37adba048 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -500,6 +500,18 @@ export function IsaacVideo(props: IsaacVideoProps) { [closeCurrentSegment, startCurrentSegment], ); + const logPlayerEvent = useCallback( + (eventType: VideoEventDetails["type"], videoUrl: string, videoId: string, videoPosition?: number) => { + const eventDetails = createEventDetails(eventType, videoUrl, videoId, { + pageId, + videoPosition: eventType === "VIDEO_ENDED" ? undefined : videoPosition, + videoDurationSeconds: progressReference.current.totalVideoDurationInSeconds ?? undefined, + }); + logVideoEvent(eventDetails, dispatch); + }, + [dispatch, pageId], + ); + // Load Wistia API script React.useEffect(() => { if (!isWistia || globalThis.Wistia) return; @@ -520,7 +532,6 @@ export function IsaacVideo(props: IsaacVideoProps) { if (!isWistia || !wistiaVideoId || !wistiaIframeRef.current) return; const iframe = wistiaIframeRef.current; - let lastKnownTime = 0; // Event type mapping for video events const eventTypeMap: Record = { @@ -540,24 +551,30 @@ export function IsaacVideo(props: IsaacVideoProps) { const updateTimeFromEventData = (eventData: WistiaEventData): void => { if (typeof eventData.seconds === "number") { - lastKnownTime = eventData.seconds; + progressReference.current.lastKnownTime = eventData.seconds; } else if (typeof eventData.secondsWatched === "number") { - lastKnownTime = eventData.secondsWatched; + progressReference.current.lastKnownTime = eventData.secondsWatched; } }; const updateTimeFromArgs = (args: Array>): void => { if (typeof args[1] === "number") { - lastKnownTime = args[1]; + progressReference.current.lastKnownTime = args[1]; } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { - lastKnownTime = (args[1] as WistiaEventData).seconds as number; + progressReference.current.lastKnownTime = (args[1] as WistiaEventData).seconds as number; } }; + const getTotalDurationInSecondsForWistiaVideoFromEventData = (eventData: WistiaEventData): number | null => { + const possibleDuration = eventData["duration"]; + return isValidNumber(possibleDuration) ? possibleDuration : null; + }; + const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { + updateTimeFromEventData(eventData); const videoUrl = embedSrc || ""; - const eventTime = getTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; - const totalVideoDurationInSeconds = getTotalVideoDurationInSecondsFromEventData(eventData); + const eventTime = progressReference.current.lastKnownTime ?? 0; + const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); } @@ -570,11 +587,11 @@ export function IsaacVideo(props: IsaacVideoProps) { startCurrentSegment(eventTime); } else { progressReference.current.isPlaying = false; - closeCurrentSegment(eventTime, videoUrl); - progressRef.current.lastKnownTime = eventTime; + closeCurrentSegment(eventTime, videoUrl, wistiaVideoId); + progressReference.current.lastKnownTime = eventTime; } - logPlayerEvent(eventType, videoUrl, eventType === "VIDEO_ENDED" ? undefined : eventTime); + logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; const isTimeChangeEvent = (eventName: string): boolean => { @@ -596,6 +613,9 @@ export function IsaacVideo(props: IsaacVideoProps) { if (isTimeChangeEvent(eventName)) { updateTimeFromArgs(data.args); + if (isValidNumber(progressReference.current.lastKnownTime)) { + updatePlaybackProgress(progressReference.current.lastKnownTime, embedSrc || "", wistiaVideoId); + } } else { handleVideoEvent(eventName, eventData); } @@ -636,7 +656,19 @@ export function IsaacVideo(props: IsaacVideoProps) { globalThis.removeEventListener("message", handleWistiaMessage); clearTimeout(timer); }; - }, [isWistia, wistiaVideoId, embedSrc, pageId, dispatch, appendSegment, setTotalVideoDurationIfPresent]); + }, [ + isWistia, + wistiaVideoId, + embedSrc, + pageId, + dispatch, + appendSegment, + setTotalVideoDurationIfPresent, + closeCurrentSegment, + logPlayerEvent, + startCurrentSegment, + updatePlaybackProgress, + ]); // YouTube video initialization const youtubeRef = useCallback( From 379ded1ee7a734d5b5a7fd8b3434f300d6b6fb9d Mon Sep 17 00:00:00 2001 From: Madhura Date: Mon, 11 May 2026 14:08:56 +0100 Subject: [PATCH 15/20] Add capturing video events for wistia video when lastKnownTime is rolling --- src/app/components/content/IsaacVideo.tsx | 37 ++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index d37adba048..4cde306720 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -496,6 +496,7 @@ export function IsaacVideo(props: IsaacVideoProps) { closeCurrentSegment(lastKnownTime, videoUrl, videoId); startCurrentSegment(currentTime); } + progressReference.current.lastKnownTime = currentTime; }, [closeCurrentSegment, startCurrentSegment], ); @@ -549,31 +550,29 @@ export function IsaacVideo(props: IsaacVideoProps) { ); }; - const updateTimeFromEventData = (eventData: WistiaEventData): void => { - if (typeof eventData.seconds === "number") { - progressReference.current.lastKnownTime = eventData.seconds; - } else if (typeof eventData.secondsWatched === "number") { - progressReference.current.lastKnownTime = eventData.secondsWatched; - } + const updateTimeFromEventData = (eventData: WistiaEventData): number | null => { + if (typeof eventData.seconds === "number") return eventData.seconds; + if (typeof eventData.secondsWatched === "number") return eventData.secondsWatched; + return null; }; - const updateTimeFromArgs = (args: Array>): void => { + const updateTimeFromArgs = (args: Array>): number | null => { if (typeof args[1] === "number") { - progressReference.current.lastKnownTime = args[1]; + return args[1]; } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { - progressReference.current.lastKnownTime = (args[1] as WistiaEventData).seconds as number; + return (args[1] as WistiaEventData).seconds as number; } + return null; }; const getTotalDurationInSecondsForWistiaVideoFromEventData = (eventData: WistiaEventData): number | null => { - const possibleDuration = eventData["duration"]; - return isValidNumber(possibleDuration) ? possibleDuration : null; + const videoDuration = eventData["duration"]; + return isValidNumber(videoDuration) ? videoDuration : null; }; const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { - updateTimeFromEventData(eventData); const videoUrl = embedSrc || ""; - const eventTime = progressReference.current.lastKnownTime ?? 0; + const eventTime = updateTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); @@ -612,9 +611,13 @@ export function IsaacVideo(props: IsaacVideoProps) { const eventData = (data.args[1] || {}) as WistiaEventData; if (isTimeChangeEvent(eventName)) { - updateTimeFromArgs(data.args); - if (isValidNumber(progressReference.current.lastKnownTime)) { - updatePlaybackProgress(progressReference.current.lastKnownTime, embedSrc || "", wistiaVideoId); + const currentTime = updateTimeFromArgs(data.args); + if (isValidNumber(currentTime)) { + updatePlaybackProgress(currentTime, embedSrc || "", wistiaVideoId); + } + const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); + if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { + setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); } } else { handleVideoEvent(eventName, eventData); @@ -636,7 +639,7 @@ export function IsaacVideo(props: IsaacVideoProps) { const setupWistiaBindings = () => { if (iframe.contentWindow) { // Bind to all the events we care about - const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange"]; + const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange", "durationchange"]; eventsToTrack.forEach((eventName) => { iframe.contentWindow?.postMessage( JSON.stringify({ From e358c85eb0f228a96d01ec51fa59251c723addc4 Mon Sep 17 00:00:00 2001 From: Madhura Date: Tue, 12 May 2026 09:56:45 +0100 Subject: [PATCH 16/20] Youtube video tracking --- src/app/components/content/IsaacVideo.tsx | 106 ++++++++++++++-------- 1 file changed, 68 insertions(+), 38 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 4cde306720..34b4fc89c6 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -40,6 +40,7 @@ interface WistiaEventData { interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; + getDuration: () => number; } interface YouTubeEvent { @@ -335,41 +336,6 @@ function createEventDetails( return details; } -function onPlayerStateChange( - event: YouTubeEvent, - videoId: string, - pageId?: string, - dispatch?: ReturnType, -): void { - const YT = globalThis.YT; - if (!YT) return; - - const videoUrl = event.target.getVideoUrl(); - const videoPosition = event.target.getCurrentTime(); - let eventType: VideoEventDetails["type"] | null = null; - - switch (event.data) { - case YT.PlayerState.PLAYING: - eventType = "VIDEO_PLAY"; - break; - case YT.PlayerState.PAUSED: - eventType = "VIDEO_PAUSE"; - break; - case YT.PlayerState.ENDED: - eventType = "VIDEO_ENDED"; - break; - default: - return; - } - - const eventDetails = createEventDetails(eventType, videoUrl, videoId, { - pageId, - videoPosition: eventType === "VIDEO_ENDED" ? undefined : videoPosition, - }); - - logVideoEvent(eventDetails, dispatch); -} - export function pauseAllVideos(): void { const iframes = document.querySelectorAll("iframe"); iframes.forEach((iframe) => { @@ -388,6 +354,8 @@ export function IsaacVideo(props: IsaacVideoProps) { const altTextToUse = `Embedded video: ${altText || src}.`; const wistiaIframeRef = useRef(null); + const youtubePlayerRef = useRef(null); + const youtubePollTimerRef = useRef | null>(null); const platform = React.useMemo(() => (src ? detectPlatform(src) : null), [src]); const isYouTube = platform === "youtube"; @@ -635,7 +603,6 @@ export function IsaacVideo(props: IsaacVideoProps) { globalThis.addEventListener("message", handleWistiaMessage); - // Setup Wistia bindings once iframe is loaded const setupWistiaBindings = () => { if (iframe.contentWindow) { // Bind to all the events we care about @@ -691,7 +658,63 @@ export function IsaacVideo(props: IsaacVideoProps) { origin: globalThis.location.origin, }, events: { - onStateChange: (event: YouTubeEvent) => onPlayerStateChange(event, youtubeVideoId, pageId, dispatch), + onReady: (event: YouTubeEvent) => { + youtubePlayerRef.current = event.target; + setTotalVideoDurationIfPresent(event.target.getDuration()); + }, + onStateChange: (event: YouTubeEvent) => { + youtubePlayerRef.current = event.target; + setTotalVideoDurationIfPresent(event.target.getDuration()); + + const videoUrl = event.target.getVideoUrl(); + const videoPosition = event.target.getCurrentTime(); + let eventType: VideoEventDetails["type"] | null = null; + + switch (event.data) { + case YT.PlayerState.PLAYING: + eventType = "VIDEO_PLAY"; + progressReference.current.isPlaying = true; + startCurrentSegment(videoPosition); + if (youtubePollTimerRef.current) { + globalThis.clearInterval(youtubePollTimerRef.current); + } + youtubePollTimerRef.current = globalThis.setInterval(() => { + const player = youtubePlayerRef.current; + if (!player) return; + updatePlaybackProgress(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); + }, 1000); + break; + case YT.PlayerState.PAUSED: + eventType = "VIDEO_PAUSE"; + progressReference.current.isPlaying = false; + closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); + progressReference.current.lastKnownTime = videoPosition; + if (youtubePollTimerRef.current) { + globalThis.clearInterval(youtubePollTimerRef.current); + youtubePollTimerRef.current = null; + } + break; + case YT.PlayerState.ENDED: + eventType = "VIDEO_ENDED"; + progressReference.current.isPlaying = false; + closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); + progressReference.current.lastKnownTime = videoPosition; + if (youtubePollTimerRef.current) { + globalThis.clearInterval(youtubePollTimerRef.current); + youtubePollTimerRef.current = null; + } + break; + default: + return; + } + + logPlayerEvent( + eventType, + videoUrl, + youtubeVideoId, + eventType === "VIDEO_ENDED" ? undefined : videoPosition, + ); + }, }, }); }); @@ -704,7 +727,14 @@ export function IsaacVideo(props: IsaacVideoProps) { }); } }, - [dispatch, pageId, youtubeVideoId], + [ + closeCurrentSegment, + logPlayerEvent, + setTotalVideoDurationIfPresent, + startCurrentSegment, + updatePlaybackProgress, + youtubeVideoId, + ], ); const detailsForPrintOut =
{altTextToUse}
; From 240be1c0ea9263888fd18b5a8a03f785654d5d9c Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 13 May 2026 11:47:30 +0100 Subject: [PATCH 17/20] cleanup video states on unmount --- src/app/components/content/IsaacVideo.tsx | 32 ++++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 34b4fc89c6..85b89bd614 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -491,12 +491,7 @@ export function IsaacVideo(props: IsaacVideoProps) { document.body.appendChild(script); }, [isWistia]); - // Setup Wistia tracking using postMessage API - // - // Wistia's iframe postMessage API allows us to track video events and position. - // We explicitly bind to play, pause, end, timechange, and secondchange events. - // The timechange/secondchange events continuously update our local lastKnownTime variable, - // which is then used when logging play/pause/end events to capture accurate video positions. + // Wistia: postMessage API — play/pause/end and time ticks feed the same segment tracker as YouTube React.useEffect(() => { if (!isWistia || !wistiaVideoId || !wistiaIframeRef.current) return; @@ -625,17 +620,19 @@ export function IsaacVideo(props: IsaacVideoProps) { return () => { globalThis.removeEventListener("message", handleWistiaMessage); clearTimeout(timer); + const lastTime = progressReference.current.lastKnownTime; + if (progressReference.current.isPlaying && isValidNumber(lastTime)) { + closeCurrentSegment(lastTime, embedSrc || "", wistiaVideoId); + } + progressReference.current.isPlaying = false; }; }, [ isWistia, wistiaVideoId, embedSrc, - pageId, - dispatch, - appendSegment, - setTotalVideoDurationIfPresent, closeCurrentSegment, logPlayerEvent, + setTotalVideoDurationIfPresent, startCurrentSegment, updatePlaybackProgress, ]); @@ -737,6 +734,21 @@ export function IsaacVideo(props: IsaacVideoProps) { ], ); + // Close any open YouTube segment and stop polling when leaving the page or changing video + React.useEffect(() => { + return () => { + if (youtubePollTimerRef.current) { + globalThis.clearInterval(youtubePollTimerRef.current); + youtubePollTimerRef.current = null; + } + const player = youtubePlayerRef.current; + if (player && youtubeVideoId) { + closeCurrentSegment(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); + progressReference.current.isPlaying = false; + } + }; + }, [closeCurrentSegment, youtubeVideoId]); + const detailsForPrintOut =
{altTextToUse}
; const accordionSectionContext = useContext(AccordionSectionContext); From a0cb5d8d6c7f41a3a218b79d2e11efd2286a7257 Mon Sep 17 00:00:00 2001 From: Madhura Date: Wed, 13 May 2026 16:14:21 +0100 Subject: [PATCH 18/20] Add filter for capturing wistia video progress based on its source from the content window --- src/app/components/content/IsaacVideo.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 85b89bd614..e667ba090d 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -563,6 +563,9 @@ export function IsaacVideo(props: IsaacVideoProps) { const handleWistiaMessage = (event: MessageEvent): void => { if (!isValidWistiaOrigin(event.origin)) return; + //Check to make sure the message is coming from the same origin as the iframe. This is to prevent XSS attacks, especially when we have multiple videos on the same page. + if (event.source !== iframe.contentWindow) return; + try { const data: WistiaPostMessageData = typeof event.data === "string" ? JSON.parse(event.data) : event.data; From 63a64fc9b86c75d3a68cc1829542b24d52a6f7d7 Mon Sep 17 00:00:00 2001 From: Madhura Date: Thu, 14 May 2026 14:24:45 +0100 Subject: [PATCH 19/20] Add userScope for checking and tracking video progress for unique logged in users --- src/app/components/content/IsaacVideo.tsx | 69 ++++++++++++++--------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index e667ba090d..2d8e450849 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -180,8 +180,9 @@ function extractVideoId(embedSrc: string, pattern: RegExp): string | null { return match ? match[1] : null; } -function getVideoProgressStorageKey(videoId: string): string { - return `${VIDEO_PROGRESS_STORAGE_PREFIX}${videoId}`; +// The video progress storage key is a combination of the user storage scope (logged-in or not logged in users) and the video id. This is to ensure that the video progress is scoped to the user. +function getVideoProgressStorageKey(userStorageScope: string, videoId: string): string { + return `${VIDEO_PROGRESS_STORAGE_PREFIX}${userStorageScope}:${videoId}`; } function isValidNumber(value: unknown): value is number { @@ -229,9 +230,11 @@ function getWatchPercent(uniqueWatchedSeconds: number, totalVideoDurationInSecon return uniqueWatchedSeconds / totalVideoDurationInSeconds; } -function loadVideoProgress(videoId: string): VideoProgressStore | null { +function loadVideoProgress(userStorageScope: string, videoId: string): VideoProgressStore | null { try { - const localStorageVideoData = globalThis.localStorage?.getItem(getVideoProgressStorageKey(videoId)); + const localStorageVideoData = globalThis.localStorage?.getItem( + getVideoProgressStorageKey(userStorageScope, videoId), + ); if (!localStorageVideoData) return null; const parsed = JSON.parse(localStorageVideoData) as Partial; const totalVideoDurationInSeconds = @@ -256,19 +259,23 @@ function loadVideoProgress(videoId: string): VideoProgressStore | null { } } -function createInitialVideoProgressState(videoId: string | null): VideoProgressState { - if (!videoId) { - return { - totalVideoDurationInSeconds: null, - segments: [], - currentSegmentStart: null, - lastKnownTime: null, - isPlaying: false, - thresholdLogged: false, - }; +function createEmptyVideoProgressState(): VideoProgressState { + return { + totalVideoDurationInSeconds: null, + segments: [], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: false, + }; +} + +function createInitialVideoProgressState(userStorageScope: string | null, videoId: string | null): VideoProgressState { + if (!userStorageScope || !videoId) { + return createEmptyVideoProgressState(); } - const stored = loadVideoProgress(videoId); + const stored = loadVideoProgress(userStorageScope, videoId); return { totalVideoDurationInSeconds: stored?.totalVideoDurationInSeconds ?? null, segments: stored?.segments ?? [], @@ -279,14 +286,15 @@ function createInitialVideoProgressState(videoId: string | null): VideoProgressS }; } -function saveVideoProgress(videoId: string, state: VideoProgressState): void { +function saveVideoProgress(userStorageScope: string | null, videoId: string, state: VideoProgressState): void { + if (!userStorageScope) return; try { const toStore: VideoProgressStore = { totalVideoDurationInSeconds: state.totalVideoDurationInSeconds, segments: state.segments, thresholdLogged: state.thresholdLogged, }; - globalThis.localStorage?.setItem(getVideoProgressStorageKey(videoId), JSON.stringify(toStore)); + globalThis.localStorage?.setItem(getVideoProgressStorageKey(userStorageScope, videoId), JSON.stringify(toStore)); } catch { // ignore localStorage failures } @@ -349,6 +357,10 @@ export function IsaacVideo(props: IsaacVideoProps) { doc: { src, altText }, } = props; const page = useAppSelector(selectors.doc.get); + const user = useAppSelector(selectors.user.loggedInOrNull); + + // Progress tracking and 60% KPI logging are scoped to logged-in users only. + const userStorageScope = user?.id != null ? String(user.id) : null; const pageId = (page && page !== NOT_FOUND && page.id) || undefined; const embedSrc = src && rewrite(src); const altTextToUse = `Embedded video: ${altText || src}.`; @@ -372,25 +384,27 @@ export function IsaacVideo(props: IsaacVideoProps) { ); const canonicalVideoId = youtubeVideoId || wistiaVideoId; - const progressReference = useRef(createInitialVideoProgressState(canonicalVideoId)); + const progressReference = useRef( + createInitialVideoProgressState(userStorageScope, canonicalVideoId), + ); React.useEffect(() => { - progressReference.current = createInitialVideoProgressState(canonicalVideoId); - }, [canonicalVideoId]); + progressReference.current = createInitialVideoProgressState(userStorageScope, canonicalVideoId); + }, [canonicalVideoId, userStorageScope]); const setTotalVideoDurationIfPresent = useCallback( (totalVideoDurationInSeconds: number | null | undefined) => { if (!canonicalVideoId || !isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; if (progressReference.current.totalVideoDurationInSeconds === totalVideoDurationInSeconds) return; progressReference.current.totalVideoDurationInSeconds = totalVideoDurationInSeconds; - saveVideoProgress(canonicalVideoId, progressReference.current); + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); }, - [canonicalVideoId], + [canonicalVideoId, userStorageScope], ); const checkIf60PercentWatchedAndLog = useCallback( (videoId: string, videoUrl: string) => { - if (!canonicalVideoId || progressReference.current.thresholdLogged) return; + if (!userStorageScope || !canonicalVideoId || progressReference.current.thresholdLogged) return; const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; @@ -399,7 +413,7 @@ export function IsaacVideo(props: IsaacVideoProps) { if (watchPercent < VIDEO_WATCH_THRESHOLD) return; progressReference.current.thresholdLogged = true; - saveVideoProgress(canonicalVideoId, progressReference.current); + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, videoId, { pageId, @@ -409,11 +423,12 @@ export function IsaacVideo(props: IsaacVideoProps) { }); logVideoEvent(eventDetails, dispatch); }, - [canonicalVideoId, dispatch, pageId], + [canonicalVideoId, dispatch, pageId, userStorageScope], ); const appendSegment = useCallback( (segmentStart: number, segmentEnd: number, videoId: string, videoUrl: string) => { + if (!userStorageScope) return; const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; @@ -426,11 +441,11 @@ export function IsaacVideo(props: IsaacVideoProps) { { watchedSegmentStart: clampedStart, watchedSegmentEnd: clampedEnd }, ]); if (canonicalVideoId) { - saveVideoProgress(canonicalVideoId, progressReference.current); + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); } checkIf60PercentWatchedAndLog(videoId, videoUrl); }, - [canonicalVideoId, checkIf60PercentWatchedAndLog], + [canonicalVideoId, checkIf60PercentWatchedAndLog, userStorageScope], ); const startCurrentSegment = useCallback((segmentStart: number) => { From 5f84c34bb986ee68e99bc92c97adf9656961f62b Mon Sep 17 00:00:00 2001 From: Madhura Date: Fri, 15 May 2026 14:47:09 +0100 Subject: [PATCH 20/20] Resolve sonarqube issues --- src/app/components/content/IsaacVideo.tsx | 151 ++++++++++++---------- 1 file changed, 81 insertions(+), 70 deletions(-) diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 2d8e450849..94ec0fc526 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -204,7 +204,7 @@ function mergeSegments(segments: WatchedSegment[]): WatchedSegment[] { // Iterate through sorted segments from the second one onwards for (let i = 1; i < sortedSegments.length; i++) { const currentSegment = sortedSegments[i]; - const lastMergedSegment = mergedSegments[mergedSegments.length - 1]; + const lastMergedSegment = mergedSegments.at(-1)!; if (currentSegment.watchedSegmentStart <= lastMergedSegment.watchedSegmentEnd + 0.5) { lastMergedSegment.watchedSegmentEnd = Math.max( @@ -245,9 +245,7 @@ function loadVideoProgress(userStorageScope: string, videoId: string): VideoProg ? mergeSegments( parsed.segments .filter( - (s): s is WatchedSegment => - isValidNumber((s as WatchedSegment).watchedSegmentStart) && - isValidNumber((s as WatchedSegment).watchedSegmentEnd), + (s): s is WatchedSegment => isValidNumber(s.watchedSegmentStart) && isValidNumber(s.watchedSegmentEnd), ) .map((s) => ({ watchedSegmentStart: s.watchedSegmentStart, watchedSegmentEnd: s.watchedSegmentEnd })), ) @@ -360,7 +358,7 @@ export function IsaacVideo(props: IsaacVideoProps) { const user = useAppSelector(selectors.user.loggedInOrNull); // Progress tracking and 60% KPI logging are scoped to logged-in users only. - const userStorageScope = user?.id != null ? String(user.id) : null; + const userStorageScope = user?.id == null ? null : String(user.id); const pageId = (page && page !== NOT_FOUND && page.id) || undefined; const embedSrc = src && rewrite(src); const altTextToUse = `Embedded video: ${altText || src}.`; @@ -655,6 +653,81 @@ export function IsaacVideo(props: IsaacVideoProps) { updatePlaybackProgress, ]); + const stopYouTubePollTimer = useCallback(() => { + if (!youtubePollTimerRef.current) return; + globalThis.clearInterval(youtubePollTimerRef.current); + youtubePollTimerRef.current = null; + }, []); + + const pollYouTubePlayerProgress = useCallback(() => { + const player = youtubePlayerRef.current; + if (!player || !youtubeVideoId) return; + updatePlaybackProgress(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); + }, [updatePlaybackProgress, youtubeVideoId]); + + const startYouTubePollTimer = useCallback(() => { + stopYouTubePollTimer(); + youtubePollTimerRef.current = globalThis.setInterval(pollYouTubePlayerProgress, 1000); + }, [pollYouTubePlayerProgress, stopYouTubePollTimer]); + + const handleYouTubePlayerReady = useCallback( + (event: YouTubeEvent) => { + youtubePlayerRef.current = event.target; + setTotalVideoDurationIfPresent(event.target.getDuration()); + }, + [setTotalVideoDurationIfPresent], + ); + + const handleYouTubePlayerStateChange = useCallback( + (event: YouTubeEvent) => { + const YT = globalThis.YT; + if (!YT || !youtubeVideoId) return; + + youtubePlayerRef.current = event.target; + setTotalVideoDurationIfPresent(event.target.getDuration()); + + const videoUrl = event.target.getVideoUrl(); + const videoPosition = event.target.getCurrentTime(); + let eventType: VideoEventDetails["type"] | null = null; + + switch (event.data) { + case YT.PlayerState.PLAYING: + eventType = "VIDEO_PLAY"; + progressReference.current.isPlaying = true; + startCurrentSegment(videoPosition); + startYouTubePollTimer(); + break; + case YT.PlayerState.PAUSED: + eventType = "VIDEO_PAUSE"; + progressReference.current.isPlaying = false; + closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); + progressReference.current.lastKnownTime = videoPosition; + stopYouTubePollTimer(); + break; + case YT.PlayerState.ENDED: + eventType = "VIDEO_ENDED"; + progressReference.current.isPlaying = false; + closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); + progressReference.current.lastKnownTime = videoPosition; + stopYouTubePollTimer(); + break; + default: + return; + } + + logPlayerEvent(eventType, videoUrl, youtubeVideoId, eventType === "VIDEO_ENDED" ? undefined : videoPosition); + }, + [ + closeCurrentSegment, + logPlayerEvent, + setTotalVideoDurationIfPresent, + startCurrentSegment, + startYouTubePollTimer, + stopYouTubePollTimer, + youtubeVideoId, + ], + ); + // YouTube video initialization const youtubeRef = useCallback( (node: HTMLDivElement | null) => { @@ -673,63 +746,8 @@ export function IsaacVideo(props: IsaacVideoProps) { origin: globalThis.location.origin, }, events: { - onReady: (event: YouTubeEvent) => { - youtubePlayerRef.current = event.target; - setTotalVideoDurationIfPresent(event.target.getDuration()); - }, - onStateChange: (event: YouTubeEvent) => { - youtubePlayerRef.current = event.target; - setTotalVideoDurationIfPresent(event.target.getDuration()); - - const videoUrl = event.target.getVideoUrl(); - const videoPosition = event.target.getCurrentTime(); - let eventType: VideoEventDetails["type"] | null = null; - - switch (event.data) { - case YT.PlayerState.PLAYING: - eventType = "VIDEO_PLAY"; - progressReference.current.isPlaying = true; - startCurrentSegment(videoPosition); - if (youtubePollTimerRef.current) { - globalThis.clearInterval(youtubePollTimerRef.current); - } - youtubePollTimerRef.current = globalThis.setInterval(() => { - const player = youtubePlayerRef.current; - if (!player) return; - updatePlaybackProgress(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); - }, 1000); - break; - case YT.PlayerState.PAUSED: - eventType = "VIDEO_PAUSE"; - progressReference.current.isPlaying = false; - closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); - progressReference.current.lastKnownTime = videoPosition; - if (youtubePollTimerRef.current) { - globalThis.clearInterval(youtubePollTimerRef.current); - youtubePollTimerRef.current = null; - } - break; - case YT.PlayerState.ENDED: - eventType = "VIDEO_ENDED"; - progressReference.current.isPlaying = false; - closeCurrentSegment(videoPosition, videoUrl, youtubeVideoId); - progressReference.current.lastKnownTime = videoPosition; - if (youtubePollTimerRef.current) { - globalThis.clearInterval(youtubePollTimerRef.current); - youtubePollTimerRef.current = null; - } - break; - default: - return; - } - - logPlayerEvent( - eventType, - videoUrl, - youtubeVideoId, - eventType === "VIDEO_ENDED" ? undefined : videoPosition, - ); - }, + onReady: handleYouTubePlayerReady, + onStateChange: handleYouTubePlayerStateChange, }, }); }); @@ -742,14 +760,7 @@ export function IsaacVideo(props: IsaacVideoProps) { }); } }, - [ - closeCurrentSegment, - logPlayerEvent, - setTotalVideoDurationIfPresent, - startCurrentSegment, - updatePlaybackProgress, - youtubeVideoId, - ], + [handleYouTubePlayerReady, handleYouTubePlayerStateChange, youtubeVideoId], ); // Close any open YouTube segment and stop polling when leaving the page or changing video