course,tasklist,task and completed validation

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-07-17 13:04:40 +08:00
parent 5b9f174718
commit 2e9ab5786c
22 changed files with 1345 additions and 117 deletions
+125
View File
@@ -0,0 +1,125 @@
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]);
}