From 6228e66d2c87d3e8e2fd9e21adabfe0dc83729e2 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 18 Oct 2026 11:10:11 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20production-grade=20video=20player=20?= =?UTF-8?q?=E2=80=94=20ambient=20mode,=20speed=20controls,=20hls.js,=20dou?= =?UTF-8?q?ble-tap,=20settings=20flyout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ambient canvas (32×18, ~8fps rAF, blur(55px)) behind video; toggleable, persisted - Speed controls 0.5×–2× via settings flyout; capRate replaces lockRate, anti-cheat preserved - hls.js dynamic import for .m3u8 URLs; Safari native HLS fallback; MP4 unchanged - Double-tap left=seek back 10s, right=blocked; single-tap toggles play on touch - Keyboard: Space/K play-pause, J/← back, L/→ blocked, ↑↓ volume, M mute, F fullscreen - Settings flyout with speed picker + ambient toggle (animated, framer-motion) - Theater mode toggle, fullscreen API, volume slider with localStorage persistence - Buffer visualization behind progress bar, hover timestamp tooltip, playhead thumb - All anti-cheat intact: maxTrustedPosRef, PiP block, visibility pause, heartbeat system --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/components/video-player.tsx | 918 +++++++++++++---------- 3 files changed, 547 insertions(+), 379 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da9acb1..e5851c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "dompurify": "^3.4.17", "emoji-datasource-apple": "^15.1.2", "framer-motion": "^12.0.0", + "hls.js": "^1.7.3", "lucide-react": "^0.468.0", "marked": "^18.1.1", "next": "15.5.27", @@ -2493,6 +2494,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4489491..1306a2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "dompurify": "^3.4.17", "emoji-datasource-apple": "^15.1.2", "framer-motion": "^12.0.0", + "hls.js": "^1.7.3", "lucide-react": "^0.468.0", "marked": "^18.1.1", "next": "15.5.27", diff --git a/frontend/src/components/video-player.tsx b/frontend/src/components/video-player.tsx index b7e4ea5..5479bf9 100644 --- a/frontend/src/components/video-player.tsx +++ b/frontend/src/components/video-player.tsx @@ -3,40 +3,60 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { + ChevronDown, + Gauge, Lock, Maximize, + Minimize, Pause, Play, RotateCcw, + Settings, SkipBack, + Volume1, Volume2, VolumeX, + Zap, } from "lucide-react"; -import { motion } from "framer-motion"; +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 VOLUME_KEY = "pargar:volume"; +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 AMBIENT_FPS = 8; +const AMBIENT_INTERVAL = 1000 / AMBIENT_FPS; +const DOUBLE_TAP_MS = 300; +const DOUBLE_TAP_SLOP = 60; -function savedVolume(): number { +// ─── localStorage helpers ───────────────────────────────────────────────────── +function readStorage(key: string, fallback: T, guard: (v: unknown) => v is T): T { 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; + 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); +const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; + +// ─── Main Component ─────────────────────────────────────────────────────────── export function VideoPlayer({ lessonId, videoUrl, @@ -50,14 +70,18 @@ export function VideoPlayer({ completionThresholdPct: number; initialProgress?: LessonProgress | null; }) { + // ── Refs ──────────────────────────────────────────────────────────────────── const videoRef = useRef(null); const containerRef = useRef(null); - // Lock first signed URL for this mount so React Query refetch cannot reset src. + const ambientCanvasRef = useRef(null); + const ambientRafRef = useRef(0); + const ambientLastTickRef = useRef(0); + const hlsRef = useRef(null); + 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); @@ -65,13 +89,28 @@ export function VideoPlayer({ 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 [ambientMode, setAmbientMode] = useState(() => readStorage("pargar:ambient", true, isBoolean)); + 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 [volume, setVolume] = useState(savedVolume); 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); @@ -80,8 +119,14 @@ export function VideoPlayer({ 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 [settingsOpen, setSettingsOpen] = 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; @@ -105,12 +150,102 @@ export function VideoPlayer({ v.currentTime = Math.max(0, to); lastTimeRef.current = v.currentTime; setPosition(v.currentTime); - window.setTimeout(() => { - programmaticSeekRef.current = false; - }, 120); + window.setTimeout(() => { programmaticSeekRef.current = false; }, 120); }, []); - // resume once, only if still near the start (don't yank mid-play) + 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); + setSettingsOpen(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 toggleAmbient = useCallback(() => { + setAmbientMode((prev) => { + writeStorage("pargar:ambient", !prev); + return !prev; + }); + }, []); + + // ── 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; @@ -119,17 +254,12 @@ export function VideoPlayer({ 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 (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; - } + 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; @@ -137,9 +267,7 @@ export function VideoPlayer({ setPosition(p); lastTimeRef.current = p; maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, p); - window.setTimeout(() => { - programmaticSeekRef.current = false; - }, 120); + window.setTimeout(() => { programmaticSeekRef.current = false; }, 120); } resumeAppliedRef.current = true; }; @@ -147,71 +275,53 @@ export function VideoPlayer({ if (videoRef.current.readyState >= 1) apply(); else videoRef.current.addEventListener("loadedmetadata", apply, { once: true }); } - } catch { - /* ignore */ - } + } catch { /* ignore */ } })(); - return () => { - cancelled = true; - }; + 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, "همگام‌سازی تماشا قطع شد — اتصال را بررسی کنید")); + // ── 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("آزمون باز شد! می‌توانید آموخته‌هایتان را محک بزنید."); } - }, - [lessonId] - ); + } 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]); + useEffect(() => { sendHeartbeatRef.current = sendHeartbeat; }, [sendHeartbeat]); - // anti-cheat: real playback only, lock rate/PiP, snap forward seeks + // ── Anti-cheat: rate cap, forward-seek block, PiP block ──────────────────── 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 capRate = () => { + const target = userSpeedRef.current; + if (Math.abs(v.playbackRate - target) > 0.01) { + v.playbackRate = target; + if (v.playbackRate > 2.01) warnSeekBlocked(); } }; @@ -223,9 +333,7 @@ export function VideoPlayer({ v.currentTime = max; lastTimeRef.current = max; setPosition(max); - window.setTimeout(() => { - programmaticSeekRef.current = false; - }, 120); + window.setTimeout(() => { programmaticSeekRef.current = false; }, 120); warnSeekBlocked(); return true; } @@ -233,41 +341,30 @@ export function VideoPlayer({ }; const onTimeUpdate = () => { - lockRate(); + 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; - } + 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)); - } + if (v.buffered.length > 0) setBufferedEnd(v.buffered.end(v.buffered.length - 1)); }; const onEnded = () => { setPlaying(false); - const delta = accRef.current; - accRef.current = 0; + 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(); + try { if (document.pictureInPictureElement === v) await document.exitPictureInPicture(); } + catch { /* ignore */ } + v.pause(); warnSeekBlocked(); })(); }; @@ -275,23 +372,22 @@ export function VideoPlayer({ v.addEventListener("ended", onEnded); v.addEventListener("seeking", snapForwardSeek); v.addEventListener("seeked", snapForwardSeek); - v.addEventListener("ratechange", lockRate); + 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", lockRate); + v.removeEventListener("ratechange", capRate); v.removeEventListener("enterpictureinpicture", blockPiP); }; }, [lessonId, warnSeekBlocked]); - // periodic verified heartbeats + visibility flush + // ── 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; @@ -299,19 +395,12 @@ export function VideoPlayer({ 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(); - } + 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); @@ -327,97 +416,85 @@ export function VideoPlayer({ }; }, [lessonId]); - // Sync volume to video element and persist to localStorage + // ── Volume / mute sync ────────────────────────────────────────────────────── 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 */ } + writeStorage("pargar:volume", volume); }, [volume, muted]); - // Keyboard shortcuts (active only when container is hovered/focused) + // ── Apply speed on mount and change ──────────────────────────────────────── useEffect(() => { - const container = containerRef.current; - if (!container) return; + 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); + }, []); + + // ── Ambient mode rAF loop ─────────────────────────────────────────────────── + useEffect(() => { + if (!ambientMode) { cancelAnimationFrame(ambientRafRef.current); return; } + const tick = (now: DOMHighResTimeStamp) => { + ambientRafRef.current = requestAnimationFrame(tick); + if (now - ambientLastTickRef.current < AMBIENT_INTERVAL) return; + ambientLastTickRef.current = now; + const v = videoRef.current; + const canvas = ambientCanvasRef.current; + if (!v || !canvas || v.readyState < 2 || v.paused) return; + try { + const ctx = canvas.getContext("2d"); + if (ctx) ctx.drawImage(v, 0, 0, canvas.width, canvas.height); + } catch { setAmbientMode(false); } // CORS taint + }; + ambientRafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(ambientRafRef.current); + }, [ambientMode, playing]); + + // ── Keyboard shortcuts ────────────────────────────────────────────────────── + useEffect(() => { 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(); + 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); - }, [flushAcc, seekProgrammatic]); // eslint-disable-line react-hooks/exhaustive-deps + }, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]); - 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]); + // ── 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); + }; + }, []); - // Exponential backoff auto-retry on error + // ── Refresh signed URL (error recovery) ──────────────────────────────────── const scheduleRetry = useCallback(() => { if (retryTimerRef.current) return; retryCountRef.current += 1; @@ -426,89 +503,124 @@ export function VideoPlayer({ retryTimerRef.current = null; void refreshSignedUrl(); }, delay); - }, [refreshSignedUrl]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - return () => { - if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current); - }; - }, []); + 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]); - const toggle = () => { - const v = videoRef.current; - if (!v) return; - if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد")); - else v.pause(); - }; + // ── Double-tap detection ──────────────────────────────────────────────────── + const handlePointerDown = useCallback((e: React.PointerEvent) => { + poke(); + if (e.pointerType !== "touch") return; // only touch triggers double-tap - const toggleFullscreen = () => { - const el = containerRef.current; - if (!el) return; - if (!document.fullscreenElement) { - void el.requestFullscreen().catch(() => undefined); + 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 { - void document.exitFullscreen().catch(() => undefined); + 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)); }; - 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]); - + // ── 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 fmt = (s: number) => { - const m = Math.floor(s / 60); - const sec = Math.floor(s % 60); - return `${m}:${sec.toString().padStart(2, "0")}`; - }; + const VolumeIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; return ( -
+
+ {/* ── Player container ─── */}
+ {/* Ambient canvas (behind video) */} +
+ +
+ + {/* Video element */}