Files
pargar/frontend/src/components/video-player.tsx
T

741 lines
25 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Lock,
Maximize,
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;
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;
}
export function VideoPlayer({
lessonId,
videoUrl,
duration,
completionThresholdPct,
initialProgress,
}: {
lessonId: number;
videoUrl: string;
duration: number;
completionThresholdPct: number;
initialProgress?: LessonProgress | null;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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 retryCountRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState<number>(savedVolume);
const [muted, setMuted] = useState(false);
const [position, setPosition] = useState(0);
const [bufferedEnd, setBufferedEnd] = 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);
// Update buffer visualization
if (v.buffered.length > 0) {
setBufferedEnd(v.buffered.end(v.buffered.length - 1));
}
};
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]);
// Sync volume to video element and persist to localStorage
useEffect(() => {
const v = videoRef.current;
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
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);
retryCountRef.current = 0;
} catch {
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
} finally {
refreshingSrcRef.current = false;
}
}, [lessonId]);
// 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);
};
}, []);
const toggle = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد"));
else v.pause();
};
const toggleFullscreen = () => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
void el.requestFullscreen().catch(() => undefined);
} else {
void document.exitFullscreen().catch(() => undefined);
}
};
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 bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / 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
ref={containerRef}
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);
retryCountRef.current = 0;
}}
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);
const errorMsg = `پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`;
setLoadError(errorMsg);
scheduleRetry();
}}
muted={muted || volume === 0}
/>
{(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={() => {
if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
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>
{/* Progress + buffer bar */}
<div className="relative">
{/* 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 />
<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="شروع از اول"
>
<RotateCcw className="h-4 w-4" />
شروع از اول
</button>
{/* 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 */}
<button
type="button"
onClick={toggleFullscreen}
className="ms-auto rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="تمام‌صفحه (F)"
title="تمام‌صفحه (F)"
>
<Maximize className="h-5 w-5" />
</button>
<span className="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>
);
}