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

951 lines
41 KiB
TypeScript
Raw Normal View History

"use client";
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, 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 AMBIENT_FPS = 8;
const AMBIENT_INTERVAL = 1000 / AMBIENT_FPS;
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 60;
// ─── localStorage helpers ─────────────────────────────────────────────────────
function readStorage<T>(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);
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";
// ─── 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<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ambientCanvasRef = useRef<HTMLCanvasElement>(null);
const ambientRafRef = useRef(0);
const ambientLastTickRef = useRef(0);
const hlsRef = useRef<Hls | null>(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<number | null>(null);
const userSpeedRef = useRef<Speed>(1);
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
// Double-tap detection
const lastTapRef = useRef<{ time: number; x: number } | null>(null);
const tapTimeoutRef = useRef<number | null>(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<Speed>(() => {
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<number | null>(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<string | null>(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<number | null>(null);
const settingsPanelRef = useRef<HTMLDivElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(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]);
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;
(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);
}, []);
// ── 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) => {
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]);
// ── Settings click-outside close ────────────────────────────────────────────
useEffect(() => {
if (!settingsOpen) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (settingsPanelRef.current?.contains(target)) return;
if (settingsBtnRef.current?.contains(target)) return;
setSettingsOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [settingsOpen]);
// ── 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<HTMLDivElement>) => {
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 (
<div
className={cn(
"space-y-4",
// Theater mode: break out of the page's max-w container with a full-bleed trick
theaterMode && "relative left-1/2 right-1/2 -mx-[50vw] w-screen",
)}
>
{/* ── Player container ─── */}
<div
ref={containerRef}
className={cn(
"group relative overflow-hidden bg-black shadow-soft",
theaterMode
? "rounded-none border-y border-border"
: "rounded-xl border border-border",
)}
onMouseMove={poke}
onPointerDown={handlePointerDown}
>
{/* Ambient canvas (behind video) */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden>
<canvas
ref={ambientCanvasRef}
width={32}
height={18}
className={cn(
"absolute inset-[-15%] h-[130%] w-[130%] transition-opacity duration-1000",
ambientMode && playing ? "opacity-100" : "opacity-0",
)}
style={{
filter: "blur(55px) saturate(1.4)",
transform: "translate3d(0,0,0)",
willChange: "filter, transform",
}}
/>
</div>
{/* Video element */}
<video
ref={videoRef}
className="relative z-10 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 = userSpeedRef.current;
setLoadError(null); setPlaying(true); poke();
}}
onPause={() => { setPlaying(false); 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);
setLoadError(`پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`);
scheduleRetry();
}}
muted={muted || volume === 0}
/>
{/* Loading spinner */}
{(loading || buffering) && !loadError && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/50">
<Skeleton className="h-16 w-16 rounded-full" />
</div>
)}
{/* Error overlay */}
{loadError && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/85 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>
)}
{/* Center play button (idle, non-playing) */}
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<AnimatePresence>
{!playing && !buffering && !loading && !loadError && (
<motion.button
key="play-btn"
type="button"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
onClick={toggle}
className="pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-opacity hover:opacity-90"
aria-label="پخش"
>
<Play className="ms-1 h-8 w-8 fill-white" />
</motion.button>
)}
</AnimatePresence>
</div>
{/* Seek indicator (double-tap feedback) */}
<AnimatePresence>
{seekIndicator && (
<motion.div
key="seek-indicator"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
className="pointer-events-none absolute start-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
<SkipBack className="h-6 w-6 text-white" />
</div>
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
</motion.div>
)}
</AnimatePresence>
{/* Controls overlay */}
<div
className={cn(
"absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/50 to-transparent pb-[env(safe-area-inset-bottom,0px)] transition-opacity duration-300",
uiIdle ? "pointer-events-none opacity-0" : "opacity-100",
)}
>
{/* Status bar */}
<div className="mb-2 flex items-center gap-1 px-4 pt-4 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 bar with hover tooltip */}
<div
className="relative mx-4 cursor-pointer"
onMouseMove={onProgressHover}
onMouseLeave={() => setHoverTime(null)}
>
{/* Hover timestamp tooltip */}
{hoverTime !== null && (
<div
className="pointer-events-none absolute -top-8 z-10 rounded bg-black/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
>
{fmt(hoverTime)}
</div>
)}
{/* Buffer track */}
<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 />
{/* Playhead thumb */}
<div
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[inset-inline-start] duration-150"
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
/>
</div>
{/* Bottom control row */}
<div className="mt-3 flex flex-wrap items-center gap-1.5 px-4 pb-3">
{/* Play/Pause */}
<button type="button" onClick={toggle} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="پخش / توقف (Space)">
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
{/* Skip back */}
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
<SkipBack className="h-5 w-5 scale-x-[-1]" />
</button>
{/* Restart */}
<button type="button" onClick={() => { flushAcc(); seekProgrammatic(0); void videoRef.current?.play().catch(() => undefined); poke(); }} 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 min-h-[44px]" aria-label="شروع از اول">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">شروع از اول</span>
</button>
{/* Volume */}
<div className="flex items-center gap-1.5">
<button type="button" onClick={() => setMuted((m) => !m)} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
<VolumeIcon 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>
{/* Speed pill — click cycles through speeds */}
<button
type="button"
onClick={cycleSpeed}
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]"
>
<Gauge className="h-4 w-4" />
<span>{speed}×</span>
</button>
{/* Spacer */}
<span className="flex-1" />
{/* Watched pct */}
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
{/* Settings gear — opens ambient / global settings only */}
<button
ref={settingsBtnRef}
type="button"
onClick={() => setSettingsOpen((o) => !o)}
className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center"
title="تنظیمات پخش"
aria-expanded={settingsOpen}
>
<Settings className={cn("h-5 w-5 transition-transform duration-200", settingsOpen && "rotate-45")} />
</button>
{/* Theater mode */}
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] sm:flex items-center justify-center" title="حالت تئاتر">
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
</button>
{/* Fullscreen */}
<button type="button" onClick={toggleFullscreen} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="تمام‌صفحه (F)" title="تمام‌صفحه (F)">
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
</button>
</div>
</div>
{/* Settings flyout — anchored above the controls bar, closes only on outside click */}
<AnimatePresence>
{settingsOpen && (
<motion.div
ref={settingsPanelRef}
key="settings"
initial={{ opacity: 0, y: 6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.97 }}
transition={{ duration: 0.13 }}
className="absolute bottom-[4.5rem] end-3 z-40 min-w-[200px] rounded-xl border border-white/10 bg-neutral-900/95 p-2 text-sm text-white shadow-xl backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
>
<p className="mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-white/40">تنظیمات پخش</p>
{/* Speed picker (full grid) */}
<p className="mb-1 px-2 text-[11px] font-semibold text-white/60">سرعت</p>
<div className="mb-2 grid grid-cols-3 gap-1">
{ALLOWED_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => { changeSpeed(s); setSettingsOpen(false); }}
className={cn(
"rounded-lg py-1.5 text-xs font-bold transition-colors",
speed === s
? "bg-primary text-primary-foreground"
: "bg-white/10 hover:bg-white/20",
)}
>
{s}×
</button>
))}
</div>
<div className="my-1.5 border-t border-white/10" />
{/* Ambient toggle */}
<button
type="button"
onClick={toggleAmbient}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-2 text-xs font-bold transition-colors",
ambientMode ? "bg-primary/20 text-primary" : "hover:bg-white/10",
)}
>
<Zap className="h-4 w-4 shrink-0" />
<span>حالت محیطی</span>
<span className={cn("ms-auto text-[10px]", ambientMode ? "text-primary" : "text-white/40")}>
{ambientMode ? "فعال" : "غیرفعال"}
</span>
</button>
</motion.div>
)}
</AnimatePresence>
{/* Quiz unlock CTA */}
<AnimatePresence>
{quizUnlockedNow && (
<motion.div
key="quiz-cta"
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>
)}
</AnimatePresence>
</div>
{/* ── Progress strip below player ─── */}
<div className={cn(
"flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card p-4",
theaterMode && "mx-auto max-w-5xl px-6",
)}>
<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>
);
}