fix: throttle request issue per video,audio as indicated requirements in units and lessons

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-07-15 17:00:46 +08:00
parent 5a9c0390e6
commit 35fb6a3544
9 changed files with 125 additions and 24 deletions
@@ -16,6 +16,11 @@ 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(/\/$/, ""); 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) ──────────────────────────────────────────── // ─── AudioBlock (Client — secure) ────────────────────────────────────────────
// //
// S3/Garage: // S3/Garage:
@@ -125,7 +130,7 @@ export function AudioBlock({ content, onWatchProgress }) {
if (onWatchProgress && el?.duration) { if (onWatchProgress && el?.duration) {
const pct = (el.currentTime / el.duration) * 100; const pct = (el.currentTime / el.duration) * 100;
const now = Date.now(); const now = Date.now();
if (now - lastReportRef.current > 3000) { if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
lastReportRef.current = now; lastReportRef.current = now;
onWatchProgress(pct); onWatchProgress(pct);
} }
@@ -1,17 +1,17 @@
import { VideoBlock } from "./VideoBlock"; import { VideoBlock } from "./VideoBlock";
export function TextVideoBlock({ content }) { export function TextVideoBlock({ content, onWatchProgress }) {
const vidLeft = content.video_position === "left"; const vidLeft = content.video_position === "left";
return ( return (
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6"> <div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{vidLeft && <VideoBlock content={content} />} {vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
<div <div
className="typeset text-sm w-full" className="typeset text-sm w-full"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>", __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
}} }}
/> />
{!vidLeft && <VideoBlock content={content} />} {!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
</div> </div>
); );
} }
@@ -23,6 +23,11 @@ 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(/\/$/, ""); 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 ─────────────────────────────────────────────────── // ─── Tooltip control button ───────────────────────────────────────────────────
function CtrlBtn({ label, onClick, children, className = "" }) { function CtrlBtn({ label, onClick, children, className = "" }) {
@@ -275,7 +280,7 @@ export function VideoBlock({ content, onWatchProgress }) {
setProgress(pct); setProgress(pct);
if (onWatchProgress) { if (onWatchProgress) {
const now = Date.now(); const now = Date.now();
if (now - lastReportRef.current > 3000) { if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
lastReportRef.current = now; lastReportRef.current = now;
onWatchProgress(pct); onWatchProgress(pct);
} }
+35
View File
@@ -160,6 +160,40 @@ export function ClientLibraryProvider({ children }) {
} }
}, []); }, []);
// Reports watch/listen playback progress for watch_percent / watch_video / listen_audio
// completion requirements — safe to call unconditionally for any standalone lesson (the
// backend no-ops when none of those types is configured on it). Fires frequently while
// playback is running (throttled ~3s by the block itself), so failures are swallowed
// rather than toasted — the next tick, or onEnded's final 100% call, catches up.
const upsertWatchProgress = useCallback(async (lessonUuid, unitUuid, percent, meta = {}) => {
try {
const { data } = await api.post(`/client/lessons/${lessonUuid}/watch-progress`, {
percent,
...(unitUuid ? { unit_uuid: unitUuid } : {}),
...(meta.blockId ? { block_id: meta.blockId } : {}),
...(meta.blockType ? { block_type: meta.blockType } : {}),
});
const result = data.data ?? null;
const cascadeLesson = result?.cascade?.lesson;
const cascadeUnit = result?.cascade?.unit;
setUnitDetail((prev) => {
if (!prev || !cascadeLesson) return prev;
const nextLessons = prev.lessons.map((l) =>
l.lesson_id === cascadeLesson.lesson_id
? { ...l, status: cascadeLesson.status }
: l
);
const is_completed = cascadeUnit ? cascadeUnit.status === "completed" : prev.is_completed;
return { ...prev, lessons: nextLessons, is_completed };
});
return result;
} catch {
return null;
}
}, []);
// ─── Resets ───────────────────────────────────────────────────────────── // ─── Resets ─────────────────────────────────────────────────────────────
const resetUnitDetail = useCallback(() => { const resetUnitDetail = useCallback(() => {
@@ -187,6 +221,7 @@ export function ClientLibraryProvider({ children }) {
submitUnitQuiz, submitUnitQuiz,
saveUnitQuizDraft, saveUnitQuizDraft,
upsertLessonProgress, upsertLessonProgress,
upsertWatchProgress,
resetUnitDetail, resetUnitDetail,
resetLesson, resetLesson,
@@ -151,7 +151,7 @@ export function PreviewBlock({ block, onWatchProgress }) {
case "video": case "video":
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />; return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />;
case "text-video": case "text-video":
return <TextVideoBlock blockId={id} content={content} readOnly />; return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} />;
case "audio": case "audio":
return <AudioBlock content={content} onWatchProgress={withBlockMeta} />; return <AudioBlock content={content} onWatchProgress={withBlockMeta} />;
case "code": case "code":
@@ -33,15 +33,16 @@ export const TYPE_DEFS = {
icon: PlayCircle, icon: PlayCircle,
entityTypes: ["lesson"], entityTypes: ["lesson"],
// Either block type satisfies this one — it's one aggregate percent across // Either block type satisfies this one — it's one aggregate percent across
// whichever is playing, unlike watch_video/listen_audio below. // whichever is playing, unlike watch_video/listen_audio below. text-video counts
requiresBlockTypes: ["video", "audio"], // as video — same <video> element under the hood, just paired with text.
requiresBlockTypes: ["video", "audio", "text-video"],
describe: () => "Learner must watch at least the configured percentage of the lesson's video/audio content.", describe: () => "Learner must watch at least the configured percentage of the lesson's video/audio content.",
}, },
watch_video: { watch_video: {
label: "Finish Watching the Full Video", label: "Finish Watching the Full Video",
icon: Video, icon: Video,
entityTypes: ["lesson"], entityTypes: ["lesson"],
requiresBlockTypes: ["video"], requiresBlockTypes: ["video", "text-video"],
describe: () => "Learner must watch every video block on this lesson all the way through (100%).", describe: () => "Learner must watch every video block on this lesson all the way through (100%).",
}, },
listen_audio: { listen_audio: {
+32 -3
View File
@@ -5,12 +5,13 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext"; import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel"; import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import LessonBlock from "../components/LessonBlock.jsx"; import LessonBlock from "../components/LessonBlock.jsx";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -30,6 +31,7 @@ const LessonDetails = () => {
const { const {
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson, getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
upsertWatchProgress,
} = useLibrary(); } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers(); const { tierMap, getTierCategories } = useClientTiers();
@@ -46,6 +48,10 @@ const LessonDetails = () => {
const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type)); const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type));
const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video"); const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video");
// Admin-configured completion requirement (null when nothing's set — default behavior).
const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null;
const RequirementIcon = requirementDef?.icon;
useEffect(() => { useEffect(() => {
getTierCategories(); getTierCategories();
getLesson(uuid); getLesson(uuid);
@@ -53,6 +59,13 @@ const LessonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]); }, [uuid]);
// watch_percent / watch_video / listen_audio — safe to always pass through, the
// backend no-ops whichever type (if any) isn't configured on this lesson.
const handleWatchProgress = useCallback((percent, meta) => {
if (!lesson?.uuid) return;
upsertWatchProgress(lesson.uuid, unit?.uuid ?? null, percent, meta);
}, [lesson, unit, upsertWatchProgress]);
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` }, { label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` }, { label: "Lessons", to: `/lessons` },
@@ -95,7 +108,8 @@ const LessonDetails = () => {
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1> <h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p> <p className="text-muted-foreground">{lesson.description ?? ""}</p>
{!hasCourse && ( {!hasCourse && (
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4"> <div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
{formatDuration(lesson.duration_seconds) && ( {formatDuration(lesson.duration_seconds) && (
<> <>
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span> <span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
@@ -105,6 +119,21 @@ const LessonDetails = () => {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"} <Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
</span> </span>
{requirementDef && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{RequirementIcon && <RequirementIcon />} {requirementDef.label}
</span>
</>
)}
</div>
{hasCompleted && (
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 w-fit">
<CheckCheck className="size-4" />
Success — you've completed this lesson.
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -139,7 +168,7 @@ const LessonDetails = () => {
</div> </div>
) : ( ) : (
<div className="w-full"> <div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} /> <LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} onWatchProgress={handleWatchProgress} />
</div> </div>
)} )}
</div> </div>
+21 -2
View File
@@ -1,7 +1,7 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { import {
House, Timer, CheckCircle2, Check, ClipboardList, Hourglass, House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -14,6 +14,7 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel"; import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -157,6 +158,10 @@ const UnitDetails = () => {
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null; const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0; const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
// Admin-configured completion requirement for the unit itself (null when nothing's set).
const requirementDef = unitDetail?.completion?.type ? TYPE_DEFS[unitDetail.completion.type] : null;
const RequirementIcon = requirementDef?.icon;
const handleLessonClick = (lesson) => { const handleLessonClick = (lesson) => {
navigate(`/lessons/${lesson.uuid}`); navigate(`/lessons/${lesson.uuid}`);
}; };
@@ -187,14 +192,28 @@ const UnitDetails = () => {
) : ( ) : (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
<span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span> <span>{lessons.length} {lessons.length === 1 ? "lesson" : "lessons"}</span>
<span>·</span> <span>·</span>
<span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span> <span className="flex items-center gap-1"><Timer /> {formatDuration(unitDetail.duration_seconds) ?? "—"} total</span>
<span>·</span> <span>·</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span> <span className="text-blue-600 dark:text-blue-400 font-medium">{completedCount} of {lessons.length} complete</span>
{requirementDef && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{RequirementIcon && <RequirementIcon />} {requirementDef.label}
</span>
</>
)}
</div> </div>
<Progress value={progressPct} /> <Progress value={progressPct} />
{unitDetail.is_completed && (
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 w-fit">
<CheckCheck className="size-4" />
Success — you've completed this unit.
</div>
)}
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
+9 -2
View File
@@ -81,7 +81,7 @@ const UnitReader = () => {
unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail, unitDetail, unitDetailLoading, unitBlocked, getUnitDetail, resetUnitDetail,
lesson, lessonLoading, getLesson, resetLesson, lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft, quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveUnitQuizDraft,
upsertLessonProgress, upsertLessonProgress, upsertWatchProgress,
} = useLibrary(); } = useLibrary();
// Tracks which lessons have been marked completed this session to avoid duplicate calls // Tracks which lessons have been marked completed this session to avoid duplicate calls
@@ -273,6 +273,13 @@ const UnitReader = () => {
saveUnitQuizDraft(uuid, selectedQuizId, answers); saveUnitQuizDraft(uuid, selectedQuizId, answers);
}, [uuid, selectedQuizId, saveUnitQuizDraft]); }, [uuid, selectedQuizId, saveUnitQuizDraft]);
// watch_percent / watch_video / listen_audio — safe to always pass through, the
// backend no-ops whichever type (if any) isn't configured on this lesson.
const handleWatchProgress = useCallback((percent, meta) => {
if (!lesson?.uuid) return;
upsertWatchProgress(lesson.uuid, uuid, percent, meta);
}, [lesson, uuid, upsertWatchProgress]);
// ── Next content item ────────────────────────────────────────────────── // ── Next content item ──────────────────────────────────────────────────
const getNextContent = useCallback(() => { const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) => const idx = allContent.findIndex((item) =>
@@ -482,7 +489,7 @@ const UnitReader = () => {
nextLabel={nextLabel} nextLabel={nextLabel}
/> />
) : ( ) : (
<LessonBlock lesson={lesson} loading={lessonLoading} /> <LessonBlock lesson={lesson} loading={lessonLoading} onWatchProgress={handleWatchProgress} />
)} )}
</div> </div>
</div> </div>