diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index 639987901e..94ec0fc526 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -10,10 +10,20 @@ 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 VideoProgressStore { + totalVideoDurationInSeconds: number | null; + segments: WatchedSegment[]; + thresholdLogged: boolean; } interface WistiaPostMessageData { @@ -30,6 +40,7 @@ interface WistiaEventData { interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; + getDuration: () => number; } interface YouTubeEvent { @@ -81,6 +92,24 @@ const VIDEO_PLATFORMS = { }, } as const; +const VIDEO_WATCH_THRESHOLD = 0.6; +const SEEK_DETECTION_TOLERANCE_SECONDS = 2.5; +const VIDEO_PROGRESS_STORAGE_PREFIX = "video-progress:"; + +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 +180,124 @@ function extractVideoId(embedSrc: string, pattern: RegExp): string | null { return match ? match[1] : null; } +// 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 { + 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)); +} + +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.at(-1)!; + + if (currentSegment.watchedSegmentStart <= lastMergedSegment.watchedSegmentEnd + 0.5) { + lastMergedSegment.watchedSegmentEnd = Math.max( + lastMergedSegment.watchedSegmentEnd, + currentSegment.watchedSegmentEnd, + ); + } else { + 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; +} + +function loadVideoProgress(userStorageScope: string, videoId: string): VideoProgressStore | null { + try { + const localStorageVideoData = globalThis.localStorage?.getItem( + getVideoProgressStorageKey(userStorageScope, videoId), + ); + if (!localStorageVideoData) return null; + const parsed = JSON.parse(localStorageVideoData) as Partial; + const totalVideoDurationInSeconds = + isValidNumber(parsed.totalVideoDurationInSeconds) && parsed.totalVideoDurationInSeconds > 0 + ? parsed.totalVideoDurationInSeconds + : null; + const segments = Array.isArray(parsed.segments) + ? mergeSegments( + parsed.segments + .filter( + (s): s is WatchedSegment => isValidNumber(s.watchedSegmentStart) && isValidNumber(s.watchedSegmentEnd), + ) + .map((s) => ({ watchedSegmentStart: s.watchedSegmentStart, watchedSegmentEnd: s.watchedSegmentEnd })), + ) + : []; + const thresholdLogged = parsed.thresholdLogged === true; + return { totalVideoDurationInSeconds, segments, thresholdLogged }; + } catch { + return null; + } +} + +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(userStorageScope, videoId); + return { + totalVideoDurationInSeconds: stored?.totalVideoDurationInSeconds ?? null, + segments: stored?.segments ?? [], + currentSegmentStart: null, + lastKnownTime: null, + isPlaying: false, + thresholdLogged: stored?.thresholdLogged ?? false, + }; +} + +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(userStorageScope, videoId), JSON.stringify(toStore)); + } catch { + // ignore localStorage failures + } +} + /** * Log video events to the backend */ @@ -177,47 +324,24 @@ 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 { - 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, - pageId, - eventType === "VIDEO_ENDED" ? undefined : videoPosition, - ); - - logVideoEvent(eventDetails, dispatch); -} - export function pauseAllVideos(): void { const iframes = document.querySelectorAll("iframe"); iframes.forEach((iframe) => { @@ -231,11 +355,17 @@ 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 ? null : String(user.id); const pageId = (page && page !== NOT_FOUND && page.id) || undefined; const embedSrc = src && rewrite(src); 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"; @@ -251,6 +381,119 @@ export function IsaacVideo(props: IsaacVideoProps) { [isYouTube, embedSrc], ); + const canonicalVideoId = youtubeVideoId || wistiaVideoId; + const progressReference = useRef( + createInitialVideoProgressState(userStorageScope, canonicalVideoId), + ); + + React.useEffect(() => { + 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(userStorageScope, canonicalVideoId, progressReference.current); + }, + [canonicalVideoId, userStorageScope], + ); + + const checkIf60PercentWatchedAndLog = useCallback( + (videoId: string, videoUrl: string) => { + if (!userStorageScope || !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(userStorageScope, canonicalVideoId, progressReference.current); + + const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, videoId, { + pageId, + videoDurationSeconds: totalVideoDurationInSeconds, + watchedSeconds: uniqueWatchedSeconds, + watchPercent, + }); + logVideoEvent(eventDetails, dispatch); + }, + [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; + + 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(userStorageScope, canonicalVideoId, progressReference.current); + } + checkIf60PercentWatchedAndLog(videoId, videoUrl); + }, + [canonicalVideoId, checkIf60PercentWatchedAndLog, userStorageScope], + ); + + const startCurrentSegment = useCallback((segmentStart: number) => { + progressReference.current.currentSegmentStart = 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( + (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], + ); + + // 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); + } + progressReference.current.lastKnownTime = currentTime; + }, + [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; @@ -261,17 +504,11 @@ 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; const iframe = wistiaIframeRef.current; - let lastKnownTime = 0; // Event type mapping for video events const eventTypeMap: Record = { @@ -289,36 +526,47 @@ export function IsaacVideo(props: IsaacVideoProps) { ); }; - const updateTimeFromEventData = (eventData: WistiaEventData): void => { - if (typeof eventData.seconds === "number") { - lastKnownTime = eventData.seconds; - } else if (typeof eventData.secondsWatched === "number") { - 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") { - lastKnownTime = args[1]; + return args[1]; } else if (typeof (args[1] as WistiaEventData)?.seconds === "number") { - 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 videoDuration = eventData["duration"]; + return isValidNumber(videoDuration) ? videoDuration : null; }; const handleVideoEvent = (eventName: string, eventData: WistiaEventData): void => { - updateTimeFromEventData(eventData); + const videoUrl = embedSrc || ""; + const eventTime = updateTimeFromEventData(eventData) ?? progressReference.current.lastKnownTime ?? 0; + const totalVideoDurationInSeconds = getTotalDurationInSecondsForWistiaVideoFromEventData(eventData); + if (isValidNumber(totalVideoDurationInSeconds) && totalVideoDurationInSeconds > 0) { + setTotalVideoDurationIfPresent(totalVideoDurationInSeconds); + } const eventType = eventTypeMap[eventName.toLowerCase()]; if (!eventType) return; - const eventDetails = createEventDetails( - eventType, - embedSrc || "", - pageId, - eventType === "VIDEO_ENDED" ? undefined : lastKnownTime, - ); + if (eventType === "VIDEO_PLAY") { + progressReference.current.isPlaying = true; + startCurrentSegment(eventTime); + } else { + progressReference.current.isPlaying = false; + closeCurrentSegment(eventTime, videoUrl, wistiaVideoId); + progressReference.current.lastKnownTime = eventTime; + } - logVideoEvent(eventDetails, dispatch); + logPlayerEvent(eventType, videoUrl, wistiaVideoId, eventType === "VIDEO_ENDED" ? undefined : eventTime); }; const isTimeChangeEvent = (eventName: string): boolean => { @@ -328,6 +576,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; @@ -339,7 +590,14 @@ export function IsaacVideo(props: IsaacVideoProps) { const eventData = (data.args[1] || {}) as WistiaEventData; if (isTimeChangeEvent(eventName)) { - updateTimeFromArgs(data.args); + 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); } @@ -356,11 +614,10 @@ 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 - const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange"]; + const eventsToTrack = ["play", "pause", "end", "timechange", "secondchange", "durationchange"]; eventsToTrack.forEach((eventName) => { iframe.contentWindow?.postMessage( JSON.stringify({ @@ -379,8 +636,97 @@ 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]); + }, [ + isWistia, + wistiaVideoId, + embedSrc, + closeCurrentSegment, + logPlayerEvent, + setTotalVideoDurationIfPresent, + startCurrentSegment, + 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( @@ -400,7 +746,8 @@ export function IsaacVideo(props: IsaacVideoProps) { origin: globalThis.location.origin, }, events: { - onStateChange: (event: YouTubeEvent) => onPlayerStateChange(event, pageId, dispatch), + onReady: handleYouTubePlayerReady, + onStateChange: handleYouTubePlayerStateChange, }, }); }); @@ -413,9 +760,24 @@ export function IsaacVideo(props: IsaacVideoProps) { }); } }, - [dispatch, pageId, youtubeVideoId], + [handleYouTubePlayerReady, handleYouTubePlayerStateChange, youtubeVideoId], ); + // 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);