595 lines
20 KiB
TypeScript
595 lines
20 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
|
import Link from "next/link";
|
||
|
|
import {
|
||
|
|
Lock,
|
||
|
|
Pause,
|
||
|
|
Play,
|
||
|
|
RotateCcw,
|
||
|
|
SkipBack,
|
||
|
|
Volume2,
|
||
|
|
VolumeX,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { motion } from "framer-motion";
|
||
|
|
import { api, http, toUserError } from "@/lib/api";
|
||
|
|
import type { Lesson, LessonProgress } from "@/lib/types";
|
||
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
||
|
|
import { Progress } from "@/components/ui/progress";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { toast } from "@/components/providers";
|
||
|
|
|
||
|
|
const INTERVAL_MS = 10_000;
|
||
|
|
const SEEK_FORWARD_SLACK = 0.35;
|
||
|
|
const CONTINUOUS_PLAY_MAX_DT = 3;
|
||
|
|
|
||
|
|
export function VideoPlayer({
|
||
|
|
lessonId,
|
||
|
|
videoUrl,
|
||
|
|
duration,
|
||
|
|
completionThresholdPct,
|
||
|
|
initialProgress,
|
||
|
|
}: {
|
||
|
|
lessonId: number;
|
||
|
|
videoUrl: string;
|
||
|
|
duration: number;
|
||
|
|
completionThresholdPct: number;
|
||
|
|
initialProgress?: LessonProgress | null;
|
||
|
|
}) {
|
||
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
|
// Lock first signed URL for this mount so React Query refetch cannot reset src.
|
||
|
|
const lockedSrcRef = useRef(videoUrl);
|
||
|
|
const resumeAppliedRef = useRef(false);
|
||
|
|
const accRef = useRef(0);
|
||
|
|
const lastTimeRef = useRef(0);
|
||
|
|
/** Furthest continuous playback position — forward seeks snap back to this. */
|
||
|
|
const maxTrustedPosRef = useRef(0);
|
||
|
|
const seekToastAtRef = useRef(0);
|
||
|
|
const seqRef = useRef(0);
|
||
|
|
const programmaticSeekRef = useRef(false);
|
||
|
|
const refreshingSrcRef = useRef(false);
|
||
|
|
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
|
||
|
|
|
||
|
|
const [playing, setPlaying] = useState(false);
|
||
|
|
const [muted, setMuted] = useState(false);
|
||
|
|
const [position, setPosition] = useState(0);
|
||
|
|
const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0);
|
||
|
|
const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked);
|
||
|
|
const [quizUnlockedNow, setQuizUnlockedNow] = useState(false);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [buffering, setBuffering] = useState(false);
|
||
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||
|
|
const [uiIdle, setUiIdle] = useState(false);
|
||
|
|
const [mediaDuration, setMediaDuration] = useState(duration);
|
||
|
|
const hideTimer = useRef<number | null>(null);
|
||
|
|
|
||
|
|
const warnSeekBlocked = useCallback(() => {
|
||
|
|
const now = Date.now();
|
||
|
|
if (now - seekToastAtRef.current < 2500) return;
|
||
|
|
seekToastAtRef.current = now;
|
||
|
|
toast.error("پرش سریع و نمایش شناور مجاز نیست");
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const flushAcc = useCallback(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
const delta = accRef.current;
|
||
|
|
accRef.current = 0;
|
||
|
|
if (delta < 0.05) return;
|
||
|
|
void sendHeartbeatRef.current(v.currentTime, delta);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const seekProgrammatic = useCallback((to: number) => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
programmaticSeekRef.current = true;
|
||
|
|
v.currentTime = Math.max(0, to);
|
||
|
|
lastTimeRef.current = v.currentTime;
|
||
|
|
setPosition(v.currentTime);
|
||
|
|
window.setTimeout(() => {
|
||
|
|
programmaticSeekRef.current = false;
|
||
|
|
}, 120);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// resume once, only if still near the start (don't yank mid-play)
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false;
|
||
|
|
resumeAppliedRef.current = false;
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
const { position: p, lastSeq } = await api<{ position: number; lastSeq?: number }>(
|
||
|
|
`/api/lessons/${lessonId}/resume`
|
||
|
|
);
|
||
|
|
if (typeof lastSeq === "number" && lastSeq > seqRef.current) {
|
||
|
|
seqRef.current = lastSeq;
|
||
|
|
}
|
||
|
|
if (cancelled || resumeAppliedRef.current || !(p > 1)) return;
|
||
|
|
const apply = () => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v || resumeAppliedRef.current) return;
|
||
|
|
if (v.currentTime >= 1) {
|
||
|
|
resumeAppliedRef.current = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const dur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : duration;
|
||
|
|
if (p < dur - 2) {
|
||
|
|
programmaticSeekRef.current = true;
|
||
|
|
v.currentTime = p;
|
||
|
|
setPosition(p);
|
||
|
|
lastTimeRef.current = p;
|
||
|
|
maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, p);
|
||
|
|
window.setTimeout(() => {
|
||
|
|
programmaticSeekRef.current = false;
|
||
|
|
}, 120);
|
||
|
|
}
|
||
|
|
resumeAppliedRef.current = true;
|
||
|
|
};
|
||
|
|
if (videoRef.current) {
|
||
|
|
if (videoRef.current.readyState >= 1) apply();
|
||
|
|
else videoRef.current.addEventListener("loadedmetadata", apply, { once: true });
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
};
|
||
|
|
}, [lessonId, duration]);
|
||
|
|
|
||
|
|
// Seed trusted position from known server progress so resume + scrub-back stay consistent.
|
||
|
|
useEffect(() => {
|
||
|
|
if (initialProgress?.lastPosition && initialProgress.lastPosition > 0) {
|
||
|
|
maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, initialProgress.lastPosition);
|
||
|
|
}
|
||
|
|
}, [initialProgress?.lastPosition]);
|
||
|
|
|
||
|
|
const sendHeartbeat = useCallback(
|
||
|
|
async (pos: number, delta: number) => {
|
||
|
|
seqRef.current += 1;
|
||
|
|
try {
|
||
|
|
const res = await api<{ watchedPct: number; quizUnlocked: boolean; quizUnlockedNow: boolean }>(
|
||
|
|
`/api/lessons/${lessonId}/heartbeat`,
|
||
|
|
{ method: "POST", body: { position: pos, delta, seq: seqRef.current } }
|
||
|
|
);
|
||
|
|
setWatchedPct(res.watchedPct);
|
||
|
|
if (res.quizUnlocked) {
|
||
|
|
setQuizUnlocked(true);
|
||
|
|
}
|
||
|
|
if (res.quizUnlockedNow) {
|
||
|
|
setQuizUnlockedNow(true);
|
||
|
|
toast.success("آزمون باز شد! میتوانید آموختههایتان را محک بزنید.");
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
const msg = (e as Error)?.message ?? "";
|
||
|
|
if (msg.includes("locked") || msg.includes("قفل")) {
|
||
|
|
toast.error(msg);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
toast.error(toUserError(e, "همگامسازی تماشا قطع شد — اتصال را بررسی کنید"));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[lessonId]
|
||
|
|
);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
sendHeartbeatRef.current = sendHeartbeat;
|
||
|
|
}, [sendHeartbeat]);
|
||
|
|
|
||
|
|
// anti-cheat: real playback only, lock rate/PiP, snap forward seeks
|
||
|
|
useEffect(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
|
||
|
|
v.disablePictureInPicture = true;
|
||
|
|
v.disableRemotePlayback = true;
|
||
|
|
v.playbackRate = 1;
|
||
|
|
v.defaultPlaybackRate = 1;
|
||
|
|
|
||
|
|
const lockRate = () => {
|
||
|
|
if (v.playbackRate !== 1) {
|
||
|
|
v.playbackRate = 1;
|
||
|
|
warnSeekBlocked();
|
||
|
|
}
|
||
|
|
if (v.defaultPlaybackRate !== 1) {
|
||
|
|
v.defaultPlaybackRate = 1;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const snapForwardSeek = () => {
|
||
|
|
if (programmaticSeekRef.current) return false;
|
||
|
|
const max = maxTrustedPosRef.current;
|
||
|
|
if (v.currentTime > max + SEEK_FORWARD_SLACK) {
|
||
|
|
programmaticSeekRef.current = true;
|
||
|
|
v.currentTime = max;
|
||
|
|
lastTimeRef.current = max;
|
||
|
|
setPosition(max);
|
||
|
|
window.setTimeout(() => {
|
||
|
|
programmaticSeekRef.current = false;
|
||
|
|
}, 120);
|
||
|
|
warnSeekBlocked();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
|
||
|
|
const onTimeUpdate = () => {
|
||
|
|
lockRate();
|
||
|
|
if (snapForwardSeek()) return;
|
||
|
|
const dt = v.currentTime - lastTimeRef.current;
|
||
|
|
if (dt > 0 && dt < CONTINUOUS_PLAY_MAX_DT) {
|
||
|
|
accRef.current += dt;
|
||
|
|
if (v.currentTime > maxTrustedPosRef.current) {
|
||
|
|
maxTrustedPosRef.current = v.currentTime;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
lastTimeRef.current = v.currentTime;
|
||
|
|
setPosition(v.currentTime);
|
||
|
|
};
|
||
|
|
|
||
|
|
const onEnded = () => {
|
||
|
|
setPlaying(false);
|
||
|
|
const delta = accRef.current;
|
||
|
|
accRef.current = 0;
|
||
|
|
void sendHeartbeatRef.current(v.currentTime, delta);
|
||
|
|
};
|
||
|
|
|
||
|
|
const blockPiP = () => {
|
||
|
|
void (async () => {
|
||
|
|
try {
|
||
|
|
if (document.pictureInPictureElement === v) {
|
||
|
|
await document.exitPictureInPicture();
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
v.pause();
|
||
|
|
warnSeekBlocked();
|
||
|
|
})();
|
||
|
|
};
|
||
|
|
|
||
|
|
v.addEventListener("timeupdate", onTimeUpdate);
|
||
|
|
v.addEventListener("ended", onEnded);
|
||
|
|
v.addEventListener("seeking", snapForwardSeek);
|
||
|
|
v.addEventListener("seeked", snapForwardSeek);
|
||
|
|
v.addEventListener("ratechange", lockRate);
|
||
|
|
v.addEventListener("enterpictureinpicture", blockPiP);
|
||
|
|
return () => {
|
||
|
|
v.removeEventListener("timeupdate", onTimeUpdate);
|
||
|
|
v.removeEventListener("ended", onEnded);
|
||
|
|
v.removeEventListener("seeking", snapForwardSeek);
|
||
|
|
v.removeEventListener("seeked", snapForwardSeek);
|
||
|
|
v.removeEventListener("ratechange", lockRate);
|
||
|
|
v.removeEventListener("enterpictureinpicture", blockPiP);
|
||
|
|
};
|
||
|
|
}, [lessonId, warnSeekBlocked]);
|
||
|
|
|
||
|
|
// periodic verified heartbeats + visibility flush
|
||
|
|
useEffect(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
|
||
|
|
const sendPending = (opts?: { evenIfPaused?: boolean }) => {
|
||
|
|
const delta = accRef.current;
|
||
|
|
if (delta < 0.05) return;
|
||
|
|
if (!opts?.evenIfPaused && (v.paused || v.ended)) return;
|
||
|
|
accRef.current = 0;
|
||
|
|
void sendHeartbeatRef.current(v.currentTime, delta);
|
||
|
|
};
|
||
|
|
|
||
|
|
const iv = window.setInterval(() => sendPending(), INTERVAL_MS);
|
||
|
|
const flush = () => sendPending({ evenIfPaused: true });
|
||
|
|
const onPause = () => sendPending({ evenIfPaused: true });
|
||
|
|
const vis = () => {
|
||
|
|
if (document.hidden) {
|
||
|
|
sendPending({ evenIfPaused: true });
|
||
|
|
if (!v.paused) v.pause();
|
||
|
|
}
|
||
|
|
if (document.pictureInPictureElement === v) {
|
||
|
|
void document.exitPictureInPicture().catch(() => undefined);
|
||
|
|
v.pause();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
v.addEventListener("pause", onPause);
|
||
|
|
document.addEventListener("visibilitychange", vis);
|
||
|
|
window.addEventListener("beforeunload", flush);
|
||
|
|
window.addEventListener("pagehide", flush);
|
||
|
|
return () => {
|
||
|
|
window.clearInterval(iv);
|
||
|
|
v.removeEventListener("pause", onPause);
|
||
|
|
document.removeEventListener("visibilitychange", vis);
|
||
|
|
window.removeEventListener("beforeunload", flush);
|
||
|
|
window.removeEventListener("pagehide", flush);
|
||
|
|
flush();
|
||
|
|
};
|
||
|
|
}, [lessonId]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (v) v.muted = muted;
|
||
|
|
}, [muted]);
|
||
|
|
|
||
|
|
const refreshSignedUrl = useCallback(async () => {
|
||
|
|
if (refreshingSrcRef.current) return;
|
||
|
|
refreshingSrcRef.current = true;
|
||
|
|
try {
|
||
|
|
const data = await http.get<{ lesson: Lesson; videoUrl: string; progress: LessonProgress | null }>(
|
||
|
|
`/api/lessons/${lessonId}`
|
||
|
|
);
|
||
|
|
if (!data.videoUrl) {
|
||
|
|
setLoadError("ویدیوی این درس در دسترس نیست.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
lockedSrcRef.current = data.videoUrl;
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (v) {
|
||
|
|
const keep = v.currentTime;
|
||
|
|
const wasPlaying = !v.paused;
|
||
|
|
v.src = data.videoUrl;
|
||
|
|
v.load();
|
||
|
|
const restore = () => {
|
||
|
|
if (keep > 0) {
|
||
|
|
programmaticSeekRef.current = true;
|
||
|
|
v.currentTime = keep;
|
||
|
|
lastTimeRef.current = keep;
|
||
|
|
window.setTimeout(() => {
|
||
|
|
programmaticSeekRef.current = false;
|
||
|
|
}, 120);
|
||
|
|
}
|
||
|
|
if (wasPlaying) void v.play().catch(() => undefined);
|
||
|
|
};
|
||
|
|
v.addEventListener("loadedmetadata", restore, { once: true });
|
||
|
|
}
|
||
|
|
if (data.progress) {
|
||
|
|
setWatchedPct(data.progress.watchedPct);
|
||
|
|
if (data.progress.quizUnlocked) setQuizUnlocked(true);
|
||
|
|
}
|
||
|
|
setLoadError(null);
|
||
|
|
setLoading(true);
|
||
|
|
} catch {
|
||
|
|
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
|
||
|
|
} finally {
|
||
|
|
refreshingSrcRef.current = false;
|
||
|
|
}
|
||
|
|
}, [lessonId]);
|
||
|
|
|
||
|
|
const toggle = () => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد"));
|
||
|
|
else v.pause();
|
||
|
|
};
|
||
|
|
|
||
|
|
const onPlayChange = useCallback(() => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
setPlaying(!!v && !v.paused);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const poke = useCallback(() => {
|
||
|
|
setUiIdle(false);
|
||
|
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (v && !v.paused && !v.ended) {
|
||
|
|
hideTimer.current = window.setTimeout(() => setUiIdle(true), 2600);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const skipBack = () => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
flushAcc();
|
||
|
|
seekProgrammatic(Math.max(0, v.currentTime - 10));
|
||
|
|
poke();
|
||
|
|
};
|
||
|
|
|
||
|
|
const restartFromStart = () => {
|
||
|
|
const v = videoRef.current;
|
||
|
|
if (!v) return;
|
||
|
|
flushAcc();
|
||
|
|
seekProgrammatic(0);
|
||
|
|
void v.play().catch(() => undefined);
|
||
|
|
poke();
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (playing) poke();
|
||
|
|
return () => {
|
||
|
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||
|
|
};
|
||
|
|
}, [playing, poke]);
|
||
|
|
|
||
|
|
const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0;
|
||
|
|
const fmt = (s: number) => {
|
||
|
|
const m = Math.floor(s / 60);
|
||
|
|
const sec = Math.floor(s % 60);
|
||
|
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div
|
||
|
|
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
|
||
|
|
onMouseMove={poke}
|
||
|
|
onPointerDown={poke}
|
||
|
|
>
|
||
|
|
<video
|
||
|
|
ref={videoRef}
|
||
|
|
src={lockedSrcRef.current}
|
||
|
|
className="aspect-video w-full"
|
||
|
|
controls={false}
|
||
|
|
controlsList="nodownload noplaybackrate noremoteplayback nofullscreen"
|
||
|
|
disablePictureInPicture
|
||
|
|
playsInline
|
||
|
|
preload="metadata"
|
||
|
|
draggable={false}
|
||
|
|
onDragStart={(e) => e.preventDefault()}
|
||
|
|
onContextMenu={(e) => e.preventDefault()}
|
||
|
|
onPlay={() => {
|
||
|
|
const el = videoRef.current;
|
||
|
|
if (el) {
|
||
|
|
el.playbackRate = 1;
|
||
|
|
el.defaultPlaybackRate = 1;
|
||
|
|
}
|
||
|
|
setLoadError(null);
|
||
|
|
onPlayChange();
|
||
|
|
poke();
|
||
|
|
}}
|
||
|
|
onPause={() => {
|
||
|
|
onPlayChange();
|
||
|
|
poke();
|
||
|
|
}}
|
||
|
|
onWaiting={() => setBuffering(true)}
|
||
|
|
onPlaying={() => {
|
||
|
|
setLoading(false);
|
||
|
|
setBuffering(false);
|
||
|
|
}}
|
||
|
|
onCanPlay={() => {
|
||
|
|
setLoading(false);
|
||
|
|
setBuffering(false);
|
||
|
|
}}
|
||
|
|
onLoadedMetadata={() => {
|
||
|
|
const el = videoRef.current;
|
||
|
|
if (el && Number.isFinite(el.duration) && el.duration > 0) {
|
||
|
|
setMediaDuration(el.duration);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onError={() => {
|
||
|
|
setLoading(false);
|
||
|
|
setBuffering(false);
|
||
|
|
setLoadError("پخش ویدیو قطع شد. در حال تلاش برای اتصال مجدد…");
|
||
|
|
void refreshSignedUrl();
|
||
|
|
}}
|
||
|
|
muted={muted}
|
||
|
|
/>
|
||
|
|
{(loading || buffering) && !loadError && (
|
||
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||
|
|
<div className="flex flex-col items-center gap-3">
|
||
|
|
<Skeleton className="h-24 w-24 rounded-full" />
|
||
|
|
<Skeleton className="h-3 w-24 rounded-full" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{loadError && (
|
||
|
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center">
|
||
|
|
<p className="text-sm font-bold text-white">{loadError}</p>
|
||
|
|
<Button type="button" variant="secondary" onClick={() => void refreshSignedUrl()}>
|
||
|
|
تلاش دوباره
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
|
||
|
|
{!playing && !buffering && !loading && !loadError && (
|
||
|
|
<motion.button
|
||
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
||
|
|
animate={{ scale: 1, opacity: 1 }}
|
||
|
|
whileHover={{ scale: 1.05 }}
|
||
|
|
onClick={toggle}
|
||
|
|
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90"
|
||
|
|
aria-label="پخش"
|
||
|
|
>
|
||
|
|
<Play className="ms-1 h-8 w-8 fill-white" />
|
||
|
|
</motion.button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={`absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-4 transition-opacity duration-300 ${
|
||
|
|
uiIdle ? "pointer-events-none opacity-0" : "opacity-100"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="mb-2 flex items-center gap-1 text-[11px] font-medium text-white/90">
|
||
|
|
<span className="rounded bg-white/10 px-2 py-0.5">تماشای تاییدشده</span>
|
||
|
|
<Lock className="h-3 w-3 opacity-60" />
|
||
|
|
<span className="opacity-80">پرش سریع غیرفعال است</span>
|
||
|
|
<span className="ms-auto tabular-nums">
|
||
|
|
{fmt(position)} / {fmt(mediaDuration)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<div className="relative">
|
||
|
|
<Progress value={pct} className="h-2 bg-white/15" gradient />
|
||
|
|
<div
|
||
|
|
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow transition-[inset-inline-start] duration-300 ease-out"
|
||
|
|
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={toggle}
|
||
|
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||
|
|
aria-label="پخش / توقف"
|
||
|
|
>
|
||
|
|
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={skipBack}
|
||
|
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||
|
|
aria-label="بازگشت ۱۰ ثانیه"
|
||
|
|
title="۱۰ ثانیه عقب"
|
||
|
|
>
|
||
|
|
<SkipBack className="h-5 w-5 scale-x-[-1]" />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={restartFromStart}
|
||
|
|
className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-xs font-bold text-white transition hover:bg-white/20"
|
||
|
|
aria-label="شروع از اول"
|
||
|
|
title="شروع از اول"
|
||
|
|
>
|
||
|
|
<RotateCcw className="h-4 w-4" />
|
||
|
|
شروع از اول
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setMuted((m) => !m)}
|
||
|
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||
|
|
aria-label="قطع صدا"
|
||
|
|
>
|
||
|
|
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||
|
|
</button>
|
||
|
|
<span className="ms-auto text-xs text-white/70">
|
||
|
|
{Math.round(watchedPct)}٪ تماشای تاییدشده
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{quizUnlockedNow && (
|
||
|
|
<motion.div
|
||
|
|
initial={{ y: 40, opacity: 0 }}
|
||
|
|
animate={{ y: 0, opacity: 1 }}
|
||
|
|
className="absolute end-4 top-4 z-30"
|
||
|
|
>
|
||
|
|
<Button asChild variant="gradient" size="lg">
|
||
|
|
<Link href={`/quiz/${lessonId}`}>شروع آزمون</Link>
|
||
|
|
</Button>
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card p-4">
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<p className="text-sm font-medium">پیشرفت تماشای تاییدشده</p>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
ضربانهای ارسالی هر {INTERVAL_MS / 1000} ثانیه، زمان واقعی تماشا را تایید میکنند. رسیدن به آستانه{" "}
|
||
|
|
{completionThresholdPct}٪ آزمون را باز میکند.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="w-40 shrink-0">
|
||
|
|
<Progress value={watchedPct} gradient smooth />
|
||
|
|
<p className="mt-1 text-end text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
|
||
|
|
</div>
|
||
|
|
{quizUnlocked ? (
|
||
|
|
<Button asChild variant="gradient">
|
||
|
|
<Link href={`/quiz/${lessonId}`}>شروع آزمون</Link>
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
<Button disabled variant="outline">
|
||
|
|
<Lock className="h-4 w-4" /> آزمون قفل است
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|