2026-10-07 19:40:26 +03:30
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import {
|
|
|
|
|
Lock,
|
2026-10-18 10:40:13 +03:30
|
|
|
Maximize,
|
2026-10-07 19:40:26 +03:30
|
|
|
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;
|
2026-10-18 10:40:13 +03:30
|
|
|
const VOLUME_KEY = "pargar:volume";
|
|
|
|
|
const MAX_RETRY_DELAY_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
function savedVolume(): number {
|
|
|
|
|
try {
|
|
|
|
|
const v = localStorage.getItem(VOLUME_KEY);
|
|
|
|
|
if (v !== null) {
|
|
|
|
|
const n = parseFloat(v);
|
|
|
|
|
if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
export function VideoPlayer({
|
|
|
|
|
lessonId,
|
|
|
|
|
videoUrl,
|
|
|
|
|
duration,
|
|
|
|
|
completionThresholdPct,
|
|
|
|
|
initialProgress,
|
|
|
|
|
}: {
|
|
|
|
|
lessonId: number;
|
|
|
|
|
videoUrl: string;
|
|
|
|
|
duration: number;
|
|
|
|
|
completionThresholdPct: number;
|
|
|
|
|
initialProgress?: LessonProgress | null;
|
|
|
|
|
}) {
|
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
2026-10-18 10:40:13 +03:30
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
2026-10-07 19:40:26 +03:30
|
|
|
// 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);
|
2026-10-18 10:40:13 +03:30
|
|
|
const retryCountRef = useRef(0);
|
|
|
|
|
const retryTimerRef = useRef<number | null>(null);
|
2026-10-07 19:40:26 +03:30
|
|
|
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
|
|
|
|
|
|
|
|
|
|
const [playing, setPlaying] = useState(false);
|
2026-10-18 10:40:13 +03:30
|
|
|
const [volume, setVolume] = useState<number>(savedVolume);
|
2026-10-07 19:40:26 +03:30
|
|
|
const [muted, setMuted] = useState(false);
|
|
|
|
|
const [position, setPosition] = useState(0);
|
2026-10-18 10:40:13 +03:30
|
|
|
const [bufferedEnd, setBufferedEnd] = useState(0);
|
2026-10-07 19:40:26 +03:30
|
|
|
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);
|
2026-10-18 10:40:13 +03:30
|
|
|
// Update buffer visualization
|
|
|
|
|
if (v.buffered.length > 0) {
|
|
|
|
|
setBufferedEnd(v.buffered.end(v.buffered.length - 1));
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-10-18 10:40:13 +03:30
|
|
|
// Sync volume to video element and persist to localStorage
|
2026-10-07 19:40:26 +03:30
|
|
|
useEffect(() => {
|
|
|
|
|
const v = videoRef.current;
|
2026-10-18 10:40:13 +03:30
|
|
|
if (!v) return;
|
|
|
|
|
v.volume = volume;
|
|
|
|
|
v.muted = muted || volume === 0;
|
|
|
|
|
try { localStorage.setItem(VOLUME_KEY, String(volume)); } catch { /* ignore */ }
|
|
|
|
|
}, [volume, muted]);
|
|
|
|
|
|
|
|
|
|
// Keyboard shortcuts (active only when container is hovered/focused)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const container = containerRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
// Only intercept when inside the player area or no input is focused
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return;
|
|
|
|
|
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
if (!v) return;
|
|
|
|
|
|
|
|
|
|
if (e.code === "Space") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (v.paused) void v.play().catch(() => undefined);
|
|
|
|
|
else v.pause();
|
|
|
|
|
} else if (e.code === "KeyM") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setMuted((m) => !m);
|
|
|
|
|
} else if (e.code === "ArrowLeft") {
|
|
|
|
|
// Only backward seek is permitted (anti-cheat)
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
flushAcc();
|
|
|
|
|
seekProgrammatic(Math.max(0, v.currentTime - 10));
|
|
|
|
|
poke();
|
|
|
|
|
} else if (e.code === "KeyF") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
toggleFullscreen();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
}, [flushAcc, seekProgrammatic]); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
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);
|
2026-10-18 10:40:13 +03:30
|
|
|
retryCountRef.current = 0;
|
2026-10-07 19:40:26 +03:30
|
|
|
} catch {
|
|
|
|
|
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
|
|
|
|
|
} finally {
|
|
|
|
|
refreshingSrcRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
}, [lessonId]);
|
|
|
|
|
|
2026-10-18 10:40:13 +03:30
|
|
|
// Exponential backoff auto-retry on error
|
|
|
|
|
const scheduleRetry = useCallback(() => {
|
|
|
|
|
if (retryTimerRef.current) return;
|
|
|
|
|
retryCountRef.current += 1;
|
|
|
|
|
const delay = Math.min(1000 * 2 ** (retryCountRef.current - 1), MAX_RETRY_DELAY_MS);
|
|
|
|
|
retryTimerRef.current = window.setTimeout(() => {
|
|
|
|
|
retryTimerRef.current = null;
|
|
|
|
|
void refreshSignedUrl();
|
|
|
|
|
}, delay);
|
|
|
|
|
}, [refreshSignedUrl]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
const toggle = () => {
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
if (!v) return;
|
|
|
|
|
if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد"));
|
|
|
|
|
else v.pause();
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-18 10:40:13 +03:30
|
|
|
const toggleFullscreen = () => {
|
|
|
|
|
const el = containerRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
if (!document.fullscreenElement) {
|
|
|
|
|
void el.requestFullscreen().catch(() => undefined);
|
|
|
|
|
} else {
|
|
|
|
|
void document.exitFullscreen().catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
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;
|
2026-10-18 10:40:13 +03:30
|
|
|
const bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / mediaDuration) * 100) : 0;
|
2026-10-07 19:40:26 +03:30
|
|
|
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
|
2026-10-18 10:40:13 +03:30
|
|
|
ref={containerRef}
|
2026-10-07 19:40:26 +03:30
|
|
|
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);
|
2026-10-18 10:40:13 +03:30
|
|
|
retryCountRef.current = 0;
|
2026-10-07 19:40:26 +03:30
|
|
|
}}
|
|
|
|
|
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);
|
2026-10-18 10:40:13 +03:30
|
|
|
const errorMsg = `پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`;
|
|
|
|
|
setLoadError(errorMsg);
|
|
|
|
|
scheduleRetry();
|
2026-10-07 19:40:26 +03:30
|
|
|
}}
|
2026-10-18 10:40:13 +03:30
|
|
|
muted={muted || volume === 0}
|
2026-10-07 19:40:26 +03:30
|
|
|
/>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
{(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>
|
|
|
|
|
)}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
{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>
|
2026-10-18 10:40:13 +03:30
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="secondary"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current);
|
|
|
|
|
retryTimerRef.current = null;
|
|
|
|
|
void refreshSignedUrl();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-10-07 19:40:26 +03:30
|
|
|
تلاش دوباره
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
<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>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
|
|
|
{/* Progress + buffer bar */}
|
2026-10-07 19:40:26 +03:30
|
|
|
<div className="relative">
|
2026-10-18 10:40:13 +03:30
|
|
|
{/* Buffered range */}
|
|
|
|
|
<div className="absolute inset-0 overflow-hidden rounded-full">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full bg-white/20 transition-[width] duration-300"
|
|
|
|
|
style={{ width: `${bufferedPct}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Progress value={pct} className="h-2 bg-white/10" gradient />
|
2026-10-07 19:40:26 +03:30
|
|
|
<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>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
<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="بازگشت ۱۰ ثانیه"
|
2026-10-18 10:40:13 +03:30
|
|
|
title="۱۰ ثانیه عقب (←)"
|
2026-10-07 19:40:26 +03:30
|
|
|
>
|
|
|
|
|
<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="شروع از اول"
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
شروع از اول
|
|
|
|
|
</button>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
|
|
|
{/* Volume control */}
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setMuted((m) => !m)}
|
|
|
|
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
|
|
|
|
aria-label="قطع صدا (M)"
|
|
|
|
|
title="قطع صدا (M)"
|
|
|
|
|
>
|
|
|
|
|
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
|
|
|
|
</button>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={1}
|
|
|
|
|
step={0.05}
|
|
|
|
|
value={muted ? 0 : volume}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const v = parseFloat(e.target.value);
|
|
|
|
|
setVolume(v);
|
|
|
|
|
if (v > 0 && muted) setMuted(false);
|
|
|
|
|
}}
|
|
|
|
|
className="h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100"
|
|
|
|
|
aria-label="میزان صدا"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Fullscreen */}
|
2026-10-07 19:40:26 +03:30
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-10-18 10:40:13 +03:30
|
|
|
onClick={toggleFullscreen}
|
|
|
|
|
className="ms-auto rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
|
|
|
|
aria-label="تمامصفحه (F)"
|
|
|
|
|
title="تمامصفحه (F)"
|
2026-10-07 19:40:26 +03:30
|
|
|
>
|
2026-10-18 10:40:13 +03:30
|
|
|
<Maximize className="h-5 w-5" />
|
2026-10-07 19:40:26 +03:30
|
|
|
</button>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
|
|
|
<span className="text-xs text-white/70">
|
2026-10-07 19:40:26 +03:30
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|