mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
126 lines
6.3 KiB
JavaScript
126 lines
6.3 KiB
JavaScript
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]);
|
|
}
|