mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
course,tasklist,task and completed validation
Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user