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
@@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback } from "react";
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -16,11 +17,6 @@ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
// a final 100% immediately regardless of this window, so completion never lags.
const WATCH_PROGRESS_THROTTLE_MS = 10000;
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
//
// S3/Garage:
@@ -39,9 +35,10 @@ const WATCH_PROGRESS_THROTTLE_MS = 10000;
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
// immediately at 100% on end. Backing the watch_percent completion requirement type.
export function AudioBlock({ content, onWatchProgress }) {
export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const audioRef = useRef(null);
const lastReportRef = useRef(0);
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
const resumedRef = useRef(false);
// ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null);
@@ -76,6 +73,8 @@ export function AudioBlock({ content, onWatchProgress }) {
setPlaying(false);
setCurrentTime(0);
setDuration(0);
guard.reset();
resumedRef.current = false;
// Legacy direct URL — no asset_id
if (!assetId && directUrl) {
@@ -127,17 +126,40 @@ export function AudioBlock({ content, onWatchProgress }) {
const onTimeUpdate = useCallback(() => {
const el = audioRef.current;
setCurrentTime(el?.currentTime ?? 0);
if (onWatchProgress && el?.duration) {
guard.trackTimeUpdate(el?.currentTime ?? 0, el?.duration);
if (el?.duration) {
const pct = (el.currentTime / el.duration) * 100;
const now = Date.now();
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
lastReportRef.current = now;
onWatchProgress(pct);
}
guard.maybeReport(pct);
}
}, [onWatchProgress]);
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
}, [guard]);
const onLoadedMeta = useCallback(() => {
const el = audioRef.current;
const d = el?.duration ?? 0;
setDuration(d);
guard.setDuration(d);
// Resume from the last position reached, once per asset. Left alone once
// fully watched (>=99%) — restarting reads better than resuming at the end.
if (el && !resumedRef.current && resumePercent > 0 && resumePercent < 99 && d) {
resumedRef.current = true;
const resumeSeconds = Math.min((resumePercent / 100) * d, d - 0.25);
el.currentTime = resumeSeconds;
setCurrentTime(resumeSeconds);
// Seeds the seek-cap so forward-seeking within already-watched territory
// works immediately, instead of clamping back to 0 until the next tick.
guard.trackTimeUpdate(resumeSeconds, d);
}
}, [guard, resumePercent]);
const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]);
const onPlay = useCallback(() => {
const el = audioRef.current;
if (el?.duration) guard.reportPlayStart((el.currentTime / el.duration) * 100);
}, [guard]);
const onPause = useCallback(() => {
const el = audioRef.current;
if (el?.duration) guard.flush((el.currentTime / el.duration) * 100);
}, [guard]);
const onSeeking = useCallback(() => guard.markSeeking(), [guard]);
const onSeeked = useCallback(() => guard.markSeeked(), [guard]);
const onProgress = useCallback(() => {
const el = audioRef.current;
if (el?.buffered.length && el.duration) {
@@ -158,13 +180,13 @@ export function AudioBlock({ content, onWatchProgress }) {
const el = audioRef.current;
const bar = e.currentTarget;
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
el.currentTime = pct * duration;
el.currentTime = guard.clampSeekTarget(pct * duration);
};
const skip = (secs) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
el.currentTime = guard.clampSeekTarget(Math.min(Math.max(0, el.currentTime + secs), duration));
};
const handleVolume = (e) => {
@@ -215,6 +237,10 @@ export function AudioBlock({ content, onWatchProgress }) {
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onPlay={onPlay}
onPause={onPause}
onSeeking={onSeeking}
onSeeked={onSeeked}
onProgress={onProgress}
preload="auto"
/>
@@ -1,17 +1,17 @@
import { VideoBlock } from "./VideoBlock";
export function TextVideoBlock({ content, onWatchProgress }) {
export function TextVideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const vidLeft = content.video_position === "left";
return (
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
<div
className="typeset text-sm w-full"
dangerouslySetInnerHTML={{
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
}}
/>
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
</div>
);
}
}
@@ -9,6 +9,7 @@ import {
import { ChevronLeft, ChevronRight } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -23,11 +24,6 @@ const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
// a final 100% immediately regardless of this window, so completion never lags.
const WATCH_PROGRESS_THROTTLE_MS = 10000;
// ─── Tooltip control button ───────────────────────────────────────────────────
function CtrlBtn({ label, onClick, children, className = "" }) {
@@ -167,10 +163,11 @@ function SettingsPanel({ speed, onSpeed, onClose }) {
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
// immediately at 100% on end. Backing the watch_percent completion requirement type;
// harmless/unused when the lesson isn't configured for it (caller just won't pass it).
export function VideoBlock({ content, onWatchProgress }) {
export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const wrapRef = useRef(null);
const vidRef = useRef(null);
const lastReportRef = useRef(0);
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
const resumedRef = useRef(false);
// ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null);
@@ -229,6 +226,8 @@ export function VideoBlock({ content, onWatchProgress }) {
setOverlayVisible(true);
setSettingsOpen(false);
setEnded(false);
guard.reset();
resumedRef.current = false;
let cancelled = false;
@@ -275,23 +274,41 @@ export function VideoBlock({ content, onWatchProgress }) {
const onTimeUpdate = () => {
setCurrentTime(v.currentTime);
guard.trackTimeUpdate(v.currentTime, v.duration);
if (v.duration) {
const pct = (v.currentTime / v.duration) * 100;
setProgress(pct);
if (onWatchProgress) {
const now = Date.now();
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
lastReportRef.current = now;
onWatchProgress(pct);
}
}
guard.maybeReport(pct);
}
};
const onLoaded = () => {
setTotalDuration(v.duration);
guard.setDuration(v.duration);
// Resume from the last position reached, once per asset. Left alone once
// fully watched (>=99%) — restarting reads better than resuming at the end.
if (!resumedRef.current && resumePercent > 0 && resumePercent < 99 && v.duration) {
resumedRef.current = true;
const resumeSeconds = Math.min((resumePercent / 100) * v.duration, v.duration - 0.25);
v.currentTime = resumeSeconds;
setCurrentTime(resumeSeconds);
setProgress(resumePercent);
// Seeds the seek-cap so forward-seeking within already-watched territory
// works immediately, instead of clamping back to 0 until the next tick.
guard.trackTimeUpdate(resumeSeconds, v.duration);
}
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => {
setPlaying(false); setOverlayVisible(false); setEnded(true);
onWatchProgress?.(100);
};
const onPlay = () => {
if (v.duration) guard.reportPlayStart((v.currentTime / v.duration) * 100);
};
const onPause = () => {
if (v.duration) guard.flush((v.currentTime / v.duration) * 100);
};
const onSeeking = () => guard.markSeeking();
const onSeeked = () => guard.markSeeked();
const onWaiting = () => setBuffering(true);
const onCanPlay = () => setBuffering(false);
const onProgress = () => {
@@ -305,9 +322,13 @@ export function VideoBlock({ content, onWatchProgress }) {
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("seeking", onSeeking);
v.addEventListener("seeked", onSeeked);
v.addEventListener("progress", onProgress);
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
if (v.readyState >= 1 && v.duration) onLoaded();
return () => {
v.removeEventListener("waiting", onWaiting);
@@ -315,6 +336,10 @@ export function VideoBlock({ content, onWatchProgress }) {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("seeking", onSeeking);
v.removeEventListener("seeked", onSeeked);
v.removeEventListener("progress", onProgress);
};
}, [blobUrl]);
@@ -372,7 +397,7 @@ export function VideoBlock({ content, onWatchProgress }) {
const handleSeek = (e) => {
const v = vidRef.current;
if (!v || !v.duration) return;
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
v.currentTime = guard.clampSeekTarget((parseFloat(e.target.value) / 100) * v.duration);
};
const handleVolumeChange = (e) => {
@@ -407,7 +432,7 @@ export function VideoBlock({ content, onWatchProgress }) {
break;
case "ArrowRight":
e.preventDefault();
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
if (vidRef.current) vidRef.current.currentTime = guard.clampSeekTarget(Math.min(vidRef.current.currentTime + 5, vidRef.current.duration));
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
resetHideTimer();
break;
@@ -445,7 +470,7 @@ export function VideoBlock({ content, onWatchProgress }) {
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
// ── States ────────────────────────────────────────────────────────────────