import { useRef, useCallback, useMemo, useEffect } from "react"; // How many progress checkpoints we aim to spread across a clip's duration, // clamped between a floor (never chattier than this even for tiny clips) and // a ceiling (routine polling shouldn't exceed roughly once a minute). const MIN_INTERVAL_MS = 8000; const MAX_INTERVAL_MS = 60000; const CHECKPOINTS_TARGET = 4; function computeThrottleMs(durationSeconds) { if (!durationSeconds) return MIN_INTERVAL_MS; // duration not known yet — stay conservative return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, (durationSeconds * 1000) / CHECKPOINTS_TARGET)); } // Shared anti-skip guard for VideoBlock/AudioBlock's watch-progress tracking. // // Tracks the furthest point actually reached during normal forward playback // (never during a seek) so seek targets can be clamped to "rewind only, never // skip ahead" — a scrub/keyboard-seek/programmatic currentTime write past that // point snaps back instead of landing. This is a client-side UX deterrent only; // the server independently re-validates elapsed wall-clock time per sample // (see recordWatchProgress) since a direct API call bypasses this entirely. // // The seek-cap only applies when `antiSkipEnabled` is true — the caller passes // this based on whether the lesson actually has a watch_percent/watch_video/ // listen_audio completion requirement configured. Without one there's nothing // being enforced, so seeking is left completely free (default: disabled). // // Routine progress reports are throttled at an interval scaled to the clip's // own duration (~4 checkpoints across it, capped at once a minute) rather than // a flat interval, so short clips still get meaningful checkpoints while long // ones don't spam the endpoint. Pausing or backgrounding the tab immediately // flushes the last-known percent regardless of the throttle, so genuine // partial progress is never silently lost just because a short clip ended // before its first scheduled checkpoint. This reporting (and the resume-seed // it feeds) stays unconditional regardless of antiSkipEnabled, since it also // backs the always-on resume-position feature. export function useMediaWatchGuard({ onWatchProgress, antiSkipEnabled = false } = {}) { const lastReportRef = useRef(0); const maxReachedRef = useRef(0); const lastPctRef = useRef(0); const durationRef = useRef(0); const seekingRef = useRef(false); const playSeededRef = useRef(false); // Call on asset change (new src) to drop all guard state for the new media. const reset = useCallback(() => { lastReportRef.current = 0; maxReachedRef.current = 0; lastPctRef.current = 0; durationRef.current = 0; seekingRef.current = false; playSeededRef.current = false; }, []); // A tiny epsilon tolerates scrubber rounding / re-clicking the current spot. const EPSILON = 0.75; const clampSeekTarget = useCallback((target) => ( !antiSkipEnabled || target <= maxReachedRef.current + EPSILON ? target : maxReachedRef.current ), [antiSkipEnabled]); const markSeeking = useCallback(() => { seekingRef.current = true; }, []); const markSeeked = useCallback(() => { seekingRef.current = false; }, []); // Call once metadata resolves — drives the adaptive throttle interval. const setDuration = useCallback((durationSeconds) => { durationRef.current = durationSeconds || 0; }, []); // Wire into the timeupdate handler — only advances the furthest-reached // marker while actually playing forward, not mid-seek. Always records the // latest percent (regardless of seek state) so pause/hidden flushes have // an honest "last known position" to report even mid-seek. const trackTimeUpdate = useCallback((currentTime, duration) => { if (!seekingRef.current) maxReachedRef.current = Math.max(maxReachedRef.current, currentTime); if (duration) lastPctRef.current = (currentTime / duration) * 100; }, []); // Unconditionally reports and resets the throttle window — used for // pause/tab-hidden flushes and the initial play-start baseline, where // waiting for the routine throttle would risk losing the sample entirely. const flush = useCallback((pct) => { if (!onWatchProgress) return; lastReportRef.current = Date.now(); onWatchProgress(pct); }, [onWatchProgress]); const maybeReport = useCallback((pct) => { if (!onWatchProgress) return; const now = Date.now(); if (now - lastReportRef.current > computeThrottleMs(durationRef.current)) { lastReportRef.current = now; onWatchProgress(pct); } }, [onWatchProgress]); // Seeds the server's wall-clock baseline the moment playback first starts, // so the first real throttled sample has an honest elapsed-time reference // rather than being compared against a progress row created lazily on // whatever percent happens to be reported first. const reportPlayStart = useCallback((pct) => { if (playSeededRef.current) return; playSeededRef.current = true; flush(pct); }, [flush]); // Tab backgrounded (switched away, minimized, etc.) — flush the last known // position immediately rather than risk it never being reported at all, // since 'visibilitychange' → hidden reliably fires before actual unload // (unlike beforeunload/unload, which browsers may cancel requests around). useEffect(() => { const onVisibilityChange = () => { if (document.visibilityState === "hidden") flush(lastPctRef.current); }; document.addEventListener("visibilitychange", onVisibilityChange); return () => document.removeEventListener("visibilitychange", onVisibilityChange); }, [flush]); // Memoized so the returned object itself is referentially stable (all of the // functions inside are already useCallback-stable) — lets callers safely list // `guard` in effect/useCallback dependency arrays without re-running on every render. return useMemo(() => ( { clampSeekTarget, trackTimeUpdate, markSeeking, markSeeked, maybeReport, flush, reportPlayStart, setDuration, reset } ), [clampSeekTarget, trackTimeUpdate, markSeeking, markSeeked, maybeReport, flush, reportPlayStart, setDuration, reset]); }