diff --git a/src/app/components/content/IsaacVideo.tsx b/src/app/components/content/IsaacVideo.tsx index e4cef68096..256dbafe2c 100644 --- a/src/app/components/content/IsaacVideo.tsx +++ b/src/app/components/content/IsaacVideo.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, useRef } from "react"; import { VideoDTO } from "../../../IsaacApiTypes"; import { selectors, useAppDispatch, useAppSelector } from "../../state"; -import { NOT_FOUND, api, ACTION_TYPE } from "../../services"; +import { NOT_FOUND, api, ACTION_TYPE, API_PATH } from "../../services"; import ReactGA from "react-ga4"; import { AccordionSectionContext } from "../../../IsaacAppTypes"; @@ -55,6 +55,7 @@ interface YouTubePlayer { getVideoUrl: () => string; getCurrentTime: () => number; getDuration: () => number; + destroy?: () => void; } interface YouTubeEvent { @@ -65,7 +66,7 @@ interface YouTubeEvent { declare global { interface Window { YT?: { - Player: new (element: HTMLElement, config: unknown) => void; + Player: new (element: HTMLElement, config: unknown) => YouTubePlayer; ready: (callback: () => void) => void; PlayerState: { PLAYING: number; @@ -76,12 +77,23 @@ declare global { Wistia?: { [key: string]: unknown; }; + // Wistia's E-v1.js embed exposes a `_wq` command queue; pushing a handler with an + // `onReady` callback yields a video API object we can read the true duration from. + _wq?: Array<{ id: string; onReady?: (video: WistiaVideoApi) => void; [key: string]: unknown }>; } // eslint-disable-next-line no-var var YT: Window["YT"]; // eslint-disable-next-line no-var var Wistia: Window["Wistia"]; + // eslint-disable-next-line no-var + var _wq: Window["_wq"]; +} + +interface WistiaVideoApi { + duration?: () => number; + time?: () => number; + [key: string]: unknown; } // Constants @@ -324,6 +336,19 @@ export function saveVideoProgress(userStorageScope: string | null, videoId: stri } } +/** + * TODO(#855) TEMPORARY diagnostic logging for the video KPI flow — currently logs unconditionally on every + * environment (including production). MUST be removed (this block and its call sites) before the prod release. + */ +export function videoDebugLog(message: string, data?: Record): void { + try { + // eslint-disable-next-line no-console + console.info(`[video-kpi] ${message}`, data ?? ""); + } catch { + // ignore (e.g. console unavailable) + } +} + /** * Log video events to the backend */ @@ -335,15 +360,62 @@ export async function logVideoEvent( dispatch({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); } + videoDebugLog("logVideoEvent -> POST /log", { + type: eventDetails.type, + videoId: eventDetails.videoId, + watchPercent: eventDetails.watchPercent, + watchedSeconds: eventDetails.watchedSeconds, + videoDurationSeconds: eventDetails.videoDurationSeconds, + dispatched: Boolean(dispatch), + }); + try { await api.logger.log(eventDetails); + videoDebugLog("logVideoEvent POST /log succeeded", { type: eventDetails.type, videoId: eventDetails.videoId }); } catch (error) { + videoDebugLog("logVideoEvent POST /log FAILED", { + type: eventDetails.type, + videoId: eventDetails.videoId, + error: error instanceof Error ? error.message : String(error), + }); if (process.env.NODE_ENV === "development") { console.warn("Failed to log video event:", error); } } } +/** + * Reliably POST a video-engagement event to /log when the page is being hidden/unloaded. + * + * A normal in-page XHR/axios POST is cancelled by the browser on navigation or tab close, which + * silently drops a threshold reached at the last moment. fetch with `keepalive: true` matches the + * existing axios request (same JSON content type + credentials, so it satisfies the identical + * cookie auth and CORS) but is allowed to outlive the document. Returns whether the request was + * dispatched (not whether the server accepted it — there is no response to await during unload). + */ +export function sendVideoEngagementBeacon(eventDetails: VideoEventDetails): boolean { + try { + void fetch(`${API_PATH}/log`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(eventDetails), + keepalive: true, + }); + videoDebugLog("sendVideoEngagementBeacon dispatched", { + videoId: eventDetails.videoId, + watchPercent: eventDetails.watchPercent, + }); + return true; + } catch (error) { + videoDebugLog("sendVideoEngagementBeacon FAILED", { + videoId: eventDetails.videoId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + /** * Create video event details object */ @@ -493,6 +565,19 @@ export function IsaacVideo(props: IsaacVideoProps) { const wistiaIframeRef = useRef(null); const youtubePlayerRef = useRef(null); const youtubePollTimerRef = useRef | null>(null); + // The YouTube iframe API is loaded `async` (public/index.html), so `globalThis.YT` is often not + // yet defined when this component's div mounts. The ref callback captures the node here; a waiter + // effect then constructs the player as soon as YT is available. `youtubePlayerCreatedRef` guards + // against constructing twice (ref callback re-fire + waiter poll). + const youtubeNodeRef = useRef(null); + const youtubePlayerCreatedRef = useRef(false); + // Set true when onReady fires. Used by the watchdog below to distinguish "handshake still pending / + // video not played yet" from "enablejsapi channel never connected" (the latter = no events ever logged). + const youtubeReadyFiredRef = useRef(false); + // Prevents re-sending VIDEO_60_PERCENT_WATCHED while an attempt is in flight. Because the KPI is + // now only persisted (thresholdLogged) after the backend confirms, without this guard the + // per-tick playback check would fire duplicate requests until the first one resolved. + const thresholdSendInFlightRef = useRef(false); const platform = React.useMemo(() => (src ? detectPlatform(src) : null), [src]); const isYouTube = platform === "youtube"; @@ -521,28 +606,86 @@ export function IsaacVideo(props: IsaacVideoProps) { progressScopeAndVideoRef.current = { userStorageScope, canonicalVideoId }; } + // TODO(#855) TEMPORARY diagnostic: log the resolved tracking scope whenever it changes, so it is + // obvious whether the 60% KPI is enabled on this video and, if not, exactly why. + React.useEffect(() => { + videoDebugLog("scope resolved", { + userStorageScope, + canonicalVideoId, + loggedIn: Boolean(user), + trackingEnabled: Boolean(userStorageScope && canonicalVideoId), + reason: !userStorageScope ? "not-logged-in" : !canonicalVideoId ? "no-canonical-video-id" : "enabled", + }); + }, [userStorageScope, canonicalVideoId, user]); + 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); + videoDebugLog("video duration captured", { videoId: canonicalVideoId, totalVideoDurationInSeconds }); }, [canonicalVideoId, userStorageScope], ); const checkIf60PercentWatchedAndLog = useCallback( - (videoId: string, videoUrl: string) => { - if (!userStorageScope || !canonicalVideoId || progressReference.current.thresholdLogged) return; + (videoId: string, videoUrl: string, liveCurrentTime?: number) => { + if ( + !userStorageScope || + !canonicalVideoId || + progressReference.current.thresholdLogged || + thresholdSendInFlightRef.current + ) { + videoDebugLog("checkIf60% skipped", { + videoId, + reason: !userStorageScope + ? "not-logged-in" + : !canonicalVideoId + ? "no-video-id" + : progressReference.current.thresholdLogged + ? "already-logged-this-video" + : "send-in-flight", + }); + return; + } const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; - if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) { + videoDebugLog("checkIf60% skipped", { videoId, reason: "no-duration-yet", totalVideoDurationInSeconds }); + return; + } + + // Include the segment currently being watched (not yet committed while playing straight through) so the + // KPI fires as soon as cumulative watch time crosses the threshold, without needing a pause/seek/end/unmount. + const segments = [...progressReference.current.segments]; + const openSegmentStart = progressReference.current.currentSegmentStart; + if ( + progressReference.current.isPlaying && + isValidNumber(openSegmentStart) && + isValidNumber(liveCurrentTime) && + liveCurrentTime > openSegmentStart + ) { + segments.push({ watchedSegmentStart: openSegmentStart, watchedSegmentEnd: liveCurrentTime }); + } - const uniqueWatchedSeconds = getUniqueWatchedSeconds(progressReference.current.segments); + const uniqueWatchedSeconds = getUniqueWatchedSeconds(mergeSegments(segments)); const watchPercent = getWatchPercent(uniqueWatchedSeconds, totalVideoDurationInSeconds); - if (watchPercent < VIDEO_WATCH_THRESHOLD) return; + if (watchPercent < VIDEO_WATCH_THRESHOLD) { + videoDebugLog("checkIf60% below threshold", { + videoId, + watchPercent: Number(watchPercent.toFixed(3)), + uniqueWatchedSeconds: Number(uniqueWatchedSeconds.toFixed(1)), + totalVideoDurationInSeconds, + }); + return; + } - progressReference.current.thresholdLogged = true; - saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); + videoDebugLog("checkIf60% THRESHOLD REACHED -> logging", { + videoId, + watchPercent: Number(watchPercent.toFixed(3)), + uniqueWatchedSeconds: Number(uniqueWatchedSeconds.toFixed(1)), + totalVideoDurationInSeconds, + }); const eventDetails = createEventDetails("VIDEO_60_PERCENT_WATCHED", videoUrl, videoId, { pageId, @@ -550,20 +693,54 @@ export function IsaacVideo(props: IsaacVideoProps) { watchedSeconds: uniqueWatchedSeconds, watchPercent, }); - logVideoEvent(eventDetails, dispatch); + + // Block duplicate sends from later playback ticks until this attempt resolves. + thresholdSendInFlightRef.current = true; + // Keep the redux LOG_EVENT dispatch (analytics + observability), but only persist + // thresholdLogged once the backend has accepted the event. Previously the flag was set + // optimistically before the POST, so any failure (transient network, or a backend that + // wasn't accepting the event yet) permanently suppressed the KPI for that user+video. + dispatch({ type: ACTION_TYPE.LOG_EVENT, eventDetails }); + api.logger + .log(eventDetails) + .then(() => { + progressReference.current.thresholdLogged = true; + saveVideoProgress(userStorageScope, canonicalVideoId, progressReference.current); + videoDebugLog("VIDEO_60_PERCENT_WATCHED recorded; thresholdLogged persisted", { videoId }); + }) + .catch((error) => { + videoDebugLog("VIDEO_60_PERCENT_WATCHED POST failed; will retry on next update", { + videoId, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + thresholdSendInFlightRef.current = false; + }); }, [canonicalVideoId, dispatch, pageId, userStorageScope], ); const appendSegment = useCallback( (segmentStart: number, segmentEnd: number, videoId: string, videoUrl: string) => { - if (!userStorageScope) return; + // TODO(#855) TEMPORARY diagnostic: log each condition that gates whether a watched segment is + // recorded (and therefore whether the 60% check can eventually fire), with the deciding values. + if (!userStorageScope) { + videoDebugLog("appendSegment SKIPPED", { reason: "not-logged-in", segmentStart, segmentEnd }); + return; + } const totalVideoDurationInSeconds = progressReference.current.totalVideoDurationInSeconds; - if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) return; + if (!isValidNumber(totalVideoDurationInSeconds) || totalVideoDurationInSeconds <= 0) { + videoDebugLog("appendSegment SKIPPED", { reason: "duration-unknown", totalVideoDurationInSeconds }); + return; + } const clampedStart = clampVideoProgressValue(segmentStart, 0, totalVideoDurationInSeconds); const clampedEnd = clampVideoProgressValue(segmentEnd, 0, totalVideoDurationInSeconds); - if (clampedEnd - clampedStart < 0.5) return; + if (clampedEnd - clampedStart < 0.5) { + videoDebugLog("appendSegment SKIPPED", { reason: "segment-too-short", clampedStart, clampedEnd }); + return; + } progressReference.current.segments = mergeSegments([ ...progressReference.current.segments, @@ -578,24 +755,55 @@ export function IsaacVideo(props: IsaacVideoProps) { 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 + // TODO(#855) diagnostic: mark the exact position a new watched segment opens. + videoDebugLog("startCurrentSegment", { segmentStart: Number(segmentStart.toFixed(1)) }); }, []); const closeCurrentSegment = useCallback( (segmentEnd: number, videoUrl: string, videoId: string) => { const currentSegmentStart = progressReference.current.currentSegmentStart; - if (!isValidNumber(currentSegmentStart)) return; + if (!isValidNumber(currentSegmentStart)) { + // TODO(#855) diagnostic: closing with no open segment usually means play/pause bookkeeping got out of sync. + videoDebugLog("closeCurrentSegment SKIPPED", { + reason: "no-open-segment", + segmentEnd: Number(segmentEnd.toFixed(1)), + }); + return; + } + videoDebugLog("closeCurrentSegment", { + from: Number(currentSegmentStart.toFixed(1)), + to: Number(segmentEnd.toFixed(1)), + }); appendSegment(currentSegmentStart, segmentEnd, videoId, videoUrl); progressReference.current.currentSegmentStart = null; }, [appendSegment], ); + // Latest closeCurrentSegment, read by the YouTube teardown below without making that effect depend on + // (and therefore re-run + tear the player down on) every callback identity change. + const closeCurrentSegmentRef = useRef(closeCurrentSegment); + closeCurrentSegmentRef.current = closeCurrentSegment; + // 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; + if (!isValidNumber(currentTime)) { + // TODO(#855) diagnostic: getCurrentTime() returned a non-number (player not ready?). + videoDebugLog("updatePlaybackProgress IGNORED", { reason: "current-time-not-a-number", currentTime }); + return; + } const lastKnownTime = progressReference.current.lastKnownTime; if (!progressReference.current.isPlaying || !isValidNumber(lastKnownTime)) { + // TODO(#855) diagnostic: this is the silent branch — a tick arrives but is dropped before any + // "playback tick" log because we think we're not playing or have no baseline time. If ticks + // vanish here while the video is visibly playing, isPlaying never got set true on PLAYING. + videoDebugLog("updatePlaybackProgress IGNORED", { + reason: !progressReference.current.isPlaying ? "not-playing" : "no-baseline-time", + isPlaying: progressReference.current.isPlaying, + lastKnownTime, + currentTime: Number(currentTime.toFixed(1)), + }); progressReference.current.lastKnownTime = currentTime; return; } @@ -603,12 +811,37 @@ export function IsaacVideo(props: IsaacVideoProps) { const diff = currentTime - lastKnownTime; const isSeek = Math.abs(diff) > SEEK_DETECTION_TOLERANCE_SECONDS; if (isSeek) { + // TODO(#855) diagnostic: a seek splits the open segment; log it so jumps vs. straight play are visible. + videoDebugLog("seek detected", { + from: Number(lastKnownTime.toFixed(1)), + to: Number(currentTime.toFixed(1)), + diff: Number(diff.toFixed(1)), + }); closeCurrentSegment(lastKnownTime, videoUrl, videoId); startCurrentSegment(currentTime); } progressReference.current.lastKnownTime = currentTime; + + // TODO(#855) diagnostic: log every playback tick regardless of login state, so the watch progress and the + // logged-in/anonymous state are visible even for anonymous sessions (the KPI itself stays logged-in only). + const tickTotalDuration = progressReference.current.totalVideoDurationInSeconds; + const positionPercent = + isValidNumber(tickTotalDuration) && tickTotalDuration > 0 ? currentTime / tickTotalDuration : null; + videoDebugLog("playback tick", { + videoId, + loggedIn: Boolean(userStorageScope), + userStorageScope, + currentTime: Number(currentTime.toFixed(1)), + totalVideoDurationInSeconds: tickTotalDuration, + positionPercent: positionPercent == null ? null : Number(positionPercent.toFixed(3)), + crossed60ByPosition: positionPercent != null && positionPercent >= VIDEO_WATCH_THRESHOLD, + }); + + // Evaluate the KPI live during playback (counting the open segment) so it fires without needing a + // pause/seek/end/navigation — otherwise a straight watch-through or a tab close never logs the event. + checkIf60PercentWatchedAndLog(videoId, videoUrl, currentTime); }, - [closeCurrentSegment, startCurrentSegment], + [checkIf60PercentWatchedAndLog, closeCurrentSegment, startCurrentSegment, userStorageScope], ); const logPlayerEvent = useCallback( @@ -625,12 +858,21 @@ export function IsaacVideo(props: IsaacVideoProps) { // Load Wistia API script React.useEffect(() => { - if (!isWistia || globalThis.Wistia) return; + if (!isWistia) return; + if (globalThis.Wistia) { + // TODO(#855) diagnostic: Wistia API already present; no injection needed. + videoDebugLog("Wistia API already loaded"); + return; + } const script = document.createElement("script"); script.src = "https://fast.wistia.com/assets/external/E-v1.js"; script.async = true; + script.addEventListener("load", () => videoDebugLog("Wistia API script LOADED")); + script.addEventListener("error", () => videoDebugLog("Wistia API script FAILED to load")); document.body.appendChild(script); + // TODO(#855) diagnostic: injected the Wistia embed script. + videoDebugLog("Wistia API script injected"); }, [isWistia]); // Wistia: postMessage API — play/pause/end and time ticks feed the same segment tracker as YouTube @@ -668,6 +910,14 @@ export function IsaacVideo(props: IsaacVideoProps) { } const eventType = WISTIA_EVENT_TYPE_MAP[eventName.toLowerCase()]; + // TODO(#855) diagnostic: log every Wistia lifecycle event, mapped type included. If mapped events + // never appear while the video plays, the postMessage bindings never took (see "Wistia bindings sent"). + videoDebugLog("Wistia event", { + eventName, + mappedType: eventType ?? "(unmapped/ignored)", + eventTime: Number(eventTime.toFixed(1)), + duration: totalVideoDurationInSeconds, + }); if (!eventType) return; if (eventType === "VIDEO_PLAY") { @@ -729,6 +979,12 @@ export function IsaacVideo(props: IsaacVideoProps) { "https://fast.wistia.net", ); }); + // TODO(#855) diagnostic: bind messages posted to the Wistia iframe. Without these, no Wistia + // events arrive and the tracker stays silent after "scope resolved". + videoDebugLog("Wistia bindings sent", { wistiaVideoId, events: eventsToTrack }); + } else { + // TODO(#855) diagnostic: iframe had no contentWindow after the 1s delay — bindings never sent. + videoDebugLog("Wistia bindings SKIPPED", { reason: "no-iframe-contentWindow", wistiaVideoId }); } }; @@ -759,31 +1015,70 @@ export function IsaacVideo(props: IsaacVideoProps) { if (!youtubePollTimerRef.current) return; globalThis.clearInterval(youtubePollTimerRef.current); youtubePollTimerRef.current = null; + // TODO(#855) diagnostic: the 1s progress poll has stopped (pause/end/unmount). + videoDebugLog("YouTube poll timer STOPPED"); }, []); const pollYouTubePlayerProgress = useCallback(() => { const player = youtubePlayerRef.current; - if (!player || !youtubeVideoId) return; - updatePlaybackProgress(player.getCurrentTime(), player.getVideoUrl(), youtubeVideoId); + if (!player || !youtubeVideoId) { + // TODO(#855) diagnostic: poll fired but we have no player/videoId to read from. + videoDebugLog("YouTube poll SKIPPED", { hasPlayer: Boolean(player), youtubeVideoId }); + return; + } + const currentTime = player.getCurrentTime(); + // TODO(#855) diagnostic: raw poll reading, before updatePlaybackProgress decides to keep or drop it. + videoDebugLog("YouTube poll", { + currentTime: isValidNumber(currentTime) ? Number(currentTime.toFixed(1)) : currentTime, + }); + updatePlaybackProgress(currentTime, player.getVideoUrl(), youtubeVideoId); }, [updatePlaybackProgress, youtubeVideoId]); const startYouTubePollTimer = useCallback(() => { stopYouTubePollTimer(); youtubePollTimerRef.current = globalThis.setInterval(pollYouTubePlayerProgress, 1000); + // TODO(#855) diagnostic: the 1s progress poll has started (should follow every PLAYING). + videoDebugLog("YouTube poll timer STARTED"); }, [pollYouTubePlayerProgress, stopYouTubePollTimer]); const handleYouTubePlayerReady = useCallback( (event: YouTubeEvent) => { youtubePlayerRef.current = event.target; - setTotalVideoDurationIfPresent(event.target.getDuration()); + youtubeReadyFiredRef.current = true; + const duration = event.target.getDuration(); + // TODO(#855) diagnostic: onReady fired — the enablejsapi handshake completed, so onStateChange/KPI + // logging will work. If this never appears, the API channel never connected (see the READY WATCHDOG). + videoDebugLog("YouTube onReady", { youtubeVideoId, duration }); + setTotalVideoDurationIfPresent(duration); }, - [setTotalVideoDurationIfPresent], + [setTotalVideoDurationIfPresent, youtubeVideoId], ); const handleYouTubePlayerStateChange = useCallback( (event: YouTubeEvent) => { const YT = globalThis.YT; - if (!YT || !youtubeVideoId) return; + // TODO(#855) diagnostic: name the raw YT state so the console reads playing/paused/ended/buffering/cued + // instead of -1..5. -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued. + const stateName = + ( + { [-1]: "unstarted", 0: "ended", 1: "playing", 2: "paused", 3: "buffering", 5: "cued" } as Record< + number, + string + > + )[event.data] ?? String(event.data); + videoDebugLog("YouTube onStateChange", { + youtubeVideoId, + rawState: event.data, + stateName, + ytPresent: Boolean(YT), + }); + if (!YT || !youtubeVideoId) { + // TODO(#855) diagnostic: if this fires we can never process play/pause/end — the whole KPI is dead here. + videoDebugLog("handleYouTubePlayerStateChange BAILED", { + reason: !YT ? "no-YT-global" : "no-youtube-video-id", + }); + return; + } youtubePlayerRef.current = event.target; setTotalVideoDurationIfPresent(event.target.getDuration()); @@ -830,15 +1125,62 @@ export function IsaacVideo(props: IsaacVideoProps) { ], ); - // YouTube video initialization + // YouTube video initialization. The ref callback only *captures the node* — it does NOT construct the + // player, because YT may not be loaded yet at mount. Construction happens in the waiter effect below. const youtubeRef = useCallback( (node: HTMLDivElement | null) => { - const YT = globalThis.YT; - if (!node || !youtubeVideoId || !YT) return; + youtubeNodeRef.current = node; + // TODO(#855) diagnostic: entry point of the YouTube KPI chain. `ytPresent:false` here just means the + // async iframe API had not loaded yet — the waiter effect will construct the player once it does. + videoDebugLog("youtubeRef invoked (node captured)", { + hasNode: Boolean(node), + youtubeVideoId, + ytPresent: Boolean(globalThis.YT), + }); + }, + [youtubeVideoId], + ); + const handleYouTubePlayerError = useCallback( + (event: YouTubeEvent) => { + // TODO(#855) diagnostic: YouTube reported a player error (2 invalid id, 5 html5, 100 not found, + // 101/150 embedding disabled). The KPI cannot run if the video refuses to embed/play. + videoDebugLog("YouTube onError", { youtubeVideoId, errorCode: event.data }); + }, + [youtubeVideoId], + ); + + // The player is constructed exactly once, but auth (userStorageScope) and the page doc (pageId) can + // resolve AFTER construction. So we register STABLE wrapper handlers that always dispatch to the latest + // handler via these refs — otherwise the player would forever call handlers that closed over the + // logged-out / no-pageId state, and never log play/pause or the KPI once the user is actually scoped. + const handleYouTubePlayerReadyRef = useRef(handleYouTubePlayerReady); + handleYouTubePlayerReadyRef.current = handleYouTubePlayerReady; + const handleYouTubePlayerStateChangeRef = useRef(handleYouTubePlayerStateChange); + handleYouTubePlayerStateChangeRef.current = handleYouTubePlayerStateChange; + const handleYouTubePlayerErrorRef = useRef(handleYouTubePlayerError); + handleYouTubePlayerErrorRef.current = handleYouTubePlayerError; + + const createYouTubePlayer = useCallback( + (container: HTMLDivElement) => { + const YT = globalThis.YT; + if (!YT || !youtubeVideoId || youtubePlayerCreatedRef.current) return; + // Commit before the async YT.ready callback so the waiter poll cannot construct a second player. + youtubePlayerCreatedRef.current = true; try { + videoDebugLog("createYouTubePlayer -> YT.ready(register)", { youtubeVideoId }); YT.ready(() => { - new YT.Player(node, { + // Hand YT a plain child node to replace with its