"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { ChevronDown, Gauge, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX, } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; import type Hls from "hls.js"; 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"; import { cn } from "@/lib/utils"; // ─── Constants ──────────────────────────────────────────────────────────────── const INTERVAL_MS = 10_000; const SEEK_FORWARD_SLACK = 0.35; const CONTINUOUS_PLAY_MAX_DT = 3; const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; type Speed = (typeof ALLOWED_SPEEDS)[number]; const MAX_RETRY_DELAY_MS = 30_000; const DOUBLE_TAP_MS = 300; const DOUBLE_TAP_SLOP = 60; // ─── localStorage helpers ───────────────────────────────────────────────────── function readStorage(key: string, fallback: T, guard: (v: unknown) => v is T): T { try { const raw = localStorage.getItem(key); if (raw === null) return fallback; const parsed: unknown = JSON.parse(raw); return guard(parsed) ? parsed : fallback; } catch { return fallback; } } function writeStorage(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ } } const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); // ─── Main Component ─────────────────────────────────────────────────────────── export function VideoPlayer({ lessonId, videoUrl, duration, completionThresholdPct, initialProgress, }: { lessonId: number; videoUrl: string; duration: number; completionThresholdPct: number; initialProgress?: LessonProgress | null; }) { // ── Refs ──────────────────────────────────────────────────────────────────── const videoRef = useRef(null); const containerRef = useRef(null); const hlsRef = useRef(null); const lockedSrcRef = useRef(videoUrl); const resumeAppliedRef = useRef(false); const accRef = useRef(0); const lastTimeRef = useRef(0); 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(null); const userSpeedRef = useRef(1); const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise>(async () => undefined); // Double-tap detection const lastTapRef = useRef<{ time: number; x: number } | null>(null); const tapTimeoutRef = useRef(null); // ── Persisted prefs ───────────────────────────────────────────────────────── const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber)); const [speed, setSpeed] = useState(() => { const s = readStorage("pargar:speed", 1, isNumber); return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1; }); // ── Playback state ────────────────────────────────────────────────────────── const [playing, setPlaying] = useState(false); const [muted, setMuted] = useState(false); const [position, setPosition] = useState(0); const [bufferedEnd, setBufferedEnd] = useState(0); const [hoverTime, setHoverTime] = useState(null); const [hoverX, setHoverX] = 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(null); const [uiIdle, setUiIdle] = useState(false); const [mediaDuration, setMediaDuration] = useState(duration); const [fullscreen, setFullscreen] = useState(false); const [theaterMode, setTheaterMode] = useState(false); const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null); const hideTimer = useRef(null); // ── Utilities ─────────────────────────────────────────────────────────────── 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); }, []); const fmt = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${sec.toString().padStart(2, "0")}`; }; 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 = useCallback(() => { const v = videoRef.current; if (!v) return; flushAcc(); seekProgrammatic(Math.max(0, v.currentTime - 10)); poke(); }, [flushAcc, seekProgrammatic, poke]); const toggle = useCallback(() => { const v = videoRef.current; if (!v) return; if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد")); else v.pause(); }, []); const toggleFullscreen = useCallback(() => { const el = containerRef.current; if (!el) return; if (!document.fullscreenElement) { void el.requestFullscreen().catch(() => undefined); } else { void document.exitFullscreen().catch(() => undefined); } }, []); const changeSpeed = useCallback((s: Speed) => { userSpeedRef.current = s; setSpeed(s); writeStorage("pargar:speed", s); const v = videoRef.current; if (v) v.playbackRate = s; }, []); const cycleSpeed = useCallback(() => { const idx = ALLOWED_SPEEDS.indexOf(userSpeedRef.current); const next = ALLOWED_SPEEDS[(idx + 1) % ALLOWED_SPEEDS.length]; changeSpeed(next); }, [changeSpeed]); // ── HLS init ──────────────────────────────────────────────────────────────── useEffect(() => { const v = videoRef.current; if (!v) return; const src = lockedSrcRef.current; const isHLS = src.includes(".m3u8"); if (isHLS) { let cancelled = false; import("hls.js").then(({ default: HlsClass }) => { if (cancelled) return; if (HlsClass.isSupported()) { const hls = new HlsClass({ maxBufferLength: 45, maxMaxBufferLength: 60, startLevel: -1, autoStartLoad: true, }); hls.loadSource(src); hls.attachMedia(v); hlsRef.current = hls; } else if (v.canPlayType("application/vnd.apple.mpegurl")) { // Safari native HLS v.src = src; } }); return () => { cancelled = true; hlsRef.current?.destroy(); hlsRef.current = null; }; } else { v.src = src; } }, [lessonId]); // ── Resume position ───────────────────────────────────────────────────────── 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]); useEffect(() => { if (initialProgress?.lastPosition && initialProgress.lastPosition > 0) { maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, initialProgress.lastPosition); } }, [initialProgress?.lastPosition]); // ── Heartbeat ─────────────────────────────────────────────────────────────── 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: rate cap, forward-seek block, PiP block ──────────────────── useEffect(() => { const v = videoRef.current; if (!v) return; v.disablePictureInPicture = true; v.disableRemotePlayback = true; const capRate = () => { const target = userSpeedRef.current; if (Math.abs(v.playbackRate - target) > 0.01) { v.playbackRate = target; if (v.playbackRate > 2.01) warnSeekBlocked(); } }; 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 = () => { capRate(); if (snapForwardSeek()) return; const dt = v.currentTime - lastTimeRef.current; // Accumulate content-time delta (backend credits content coverage) 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); 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", capRate); v.addEventListener("enterpictureinpicture", blockPiP); return () => { v.removeEventListener("timeupdate", onTimeUpdate); v.removeEventListener("ended", onEnded); v.removeEventListener("seeking", snapForwardSeek); v.removeEventListener("seeked", snapForwardSeek); v.removeEventListener("ratechange", capRate); v.removeEventListener("enterpictureinpicture", blockPiP); }; }, [lessonId, warnSeekBlocked]); // ── Periodic 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]); // ── Volume / mute sync ────────────────────────────────────────────────────── useEffect(() => { const v = videoRef.current; if (!v) return; v.volume = volume; v.muted = muted || volume === 0; writeStorage("pargar:volume", volume); }, [volume, muted]); // ── Apply speed on mount and change ──────────────────────────────────────── useEffect(() => { const v = videoRef.current; if (v) { v.playbackRate = speed; userSpeedRef.current = speed; } }, [speed]); // ── Fullscreen change listener ────────────────────────────────────────────── useEffect(() => { const onFsChange = () => setFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onFsChange); return () => document.removeEventListener("fullscreenchange", onFsChange); }, []); // ── Keyboard shortcuts ────────────────────────────────────────────────────── useEffect(() => { const onKey = (e: KeyboardEvent) => { const active = document.activeElement; if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return; const v = videoRef.current; if (!v) return; switch (e.code) { case "Space": case "KeyK": e.preventDefault(); toggle(); poke(); break; case "KeyJ": case "ArrowLeft": e.preventDefault(); skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); break; case "KeyL": case "ArrowRight": e.preventDefault(); warnSeekBlocked(); break; case "ArrowUp": e.preventDefault(); setVolume((vol) => Math.min(1, Math.round((vol + 0.1) * 10) / 10)); poke(); break; case "ArrowDown": e.preventDefault(); setVolume((vol) => Math.max(0, Math.round((vol - 0.1) * 10) / 10)); poke(); break; case "KeyM": e.preventDefault(); setMuted((m) => !m); poke(); break; case "KeyF": e.preventDefault(); toggleFullscreen(); break; } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]); // ── Cleanup timers ────────────────────────────────────────────────────────── useEffect(() => { return () => { if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current); if (tapTimeoutRef.current) window.clearTimeout(tapTimeoutRef.current); if (hideTimer.current) window.clearTimeout(hideTimer.current); }; }, []); // ── Refresh signed URL (error recovery) ──────────────────────────────────── 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); }, []); // 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; if (hlsRef.current) { hlsRef.current.loadSource(data.videoUrl); } else { 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]); // ── Double-tap detection ──────────────────────────────────────────────────── const handlePointerDown = useCallback((e: React.PointerEvent) => { poke(); if (e.pointerType !== "touch") return; // only touch triggers double-tap const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; const now = Date.now(); const lastTap = lastTapRef.current; if (lastTap && now - lastTap.time < DOUBLE_TAP_MS && Math.abs(x - lastTap.x) < DOUBLE_TAP_SLOP) { lastTapRef.current = null; if (tapTimeoutRef.current) { window.clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = null; } // Double-tap: left half = seek back, right half = blocked if (x < rect.width / 2) { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); } else { warnSeekBlocked(); } } else { lastTapRef.current = { time: now, x }; if (tapTimeoutRef.current) window.clearTimeout(tapTimeoutRef.current); tapTimeoutRef.current = window.setTimeout(() => { lastTapRef.current = null; tapTimeoutRef.current = null; // Single tap: toggle play on mobile toggle(); }, DOUBLE_TAP_MS); } }, [poke, skipBack, warnSeekBlocked, toggle]); // ── Progress bar hover ────────────────────────────────────────────────────── const onProgressHover = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); setHoverTime(frac * mediaDuration); setHoverX((e.clientX - rect.left)); }; // ── Derived values ────────────────────────────────────────────────────────── const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0; const bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / mediaDuration) * 100) : 0; const VolumeIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; return (
{/* ── Player container ─── */}
{/* Video element */}