- timeline: unified dir="ltr" container; buffer and progress as stacked absolute fills in correct z-order - playhead: physical left property instead of insetInlineStart (fixes RTL mirroring) - control bar: dir="ltr" explicit groups — left: play/rewind/volume/speed, right: theater/fullscreen - rewind: replace flipped SkipBack with Rewind icon; seek indicator at physical right-8 - ambient effect: removed entirely (canvas, rAF loop, state, constants, toggle) - settings menu: removed (speed accessible via pill; ambient gone) - restart button: removed
814 lines
36 KiB
TypeScript
814 lines
36 KiB
TypeScript
"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<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);
|
||
|
||
// ─── 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 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 [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 [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
||
|
||
const hideTimer = useRef<number | null>(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<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: on mobile remove page padding; on sm+ break out to full-bleed
|
||
theaterMode && "max-sm:-mx-4 max-sm:w-[calc(100%+2rem)] sm:relative sm:left-1/2 sm:right-1/2 sm:-mx-[50vw] sm: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}
|
||
>
|
||
{/* 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 (rewind feedback — physical right side) */}
|
||
<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 right-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">
|
||
<Rewind 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>
|
||
|
||
{/* Timeline — unified dir="ltr" coordinate space */}
|
||
<div
|
||
dir="ltr"
|
||
className="relative mx-4 cursor-pointer py-2"
|
||
onMouseMove={onProgressHover}
|
||
onMouseLeave={() => setHoverTime(null)}
|
||
>
|
||
{/* Hover timestamp tooltip */}
|
||
{hoverTime !== null && (
|
||
<div
|
||
className="pointer-events-none absolute -top-6 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>
|
||
)}
|
||
{/* Track layers stacked bottom-to-top: base → buffer → progress → thumb */}
|
||
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||
{/* Buffer layer */}
|
||
<div
|
||
className="absolute left-0 top-0 h-full rounded-full bg-white/25 transition-[width] duration-300"
|
||
style={{ width: `${bufferedPct}%` }}
|
||
/>
|
||
{/* Play progress layer */}
|
||
<div
|
||
className="absolute left-0 top-0 h-full rounded-full bg-primary transition-[width] duration-150"
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
{/* Playhead thumb — positioned outside overflow:hidden, same dir="ltr" space */}
|
||
<div
|
||
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[left] duration-150"
|
||
style={{ left: `calc(${pct}% - 8px)` }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Bottom control row — dir="ltr" for physical left→right placement */}
|
||
<div dir="ltr" className="mt-1 flex items-center justify-between gap-1 px-4 pb-3">
|
||
{/* Left group: playback controls + volume + speed */}
|
||
<div className="flex items-center gap-1">
|
||
{/* Play/Pause */}
|
||
<button type="button" onClick={toggle} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="پخش / توقف (Space)">
|
||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||
</button>
|
||
|
||
{/* Rewind 10s */}
|
||
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
|
||
<Rewind className="h-5 w-5" />
|
||
</button>
|
||
|
||
{/* Volume */}
|
||
<button type="button" onClick={() => setMuted((m) => !m)} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" 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="hidden h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100 sm:block" aria-label="میزان صدا" />
|
||
|
||
{/* Speed pill */}
|
||
<button
|
||
type="button"
|
||
onClick={cycleSpeed}
|
||
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
|
||
className="inline-flex min-h-[44px] 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"
|
||
>
|
||
<Gauge className="h-4 w-4" />
|
||
<span>{speed}×</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Right group: watched%, theater, fullscreen */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
|
||
|
||
{/* Theater mode */}
|
||
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 sm:flex" title="حالت تئاتر">
|
||
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
|
||
</button>
|
||
|
||
{/* Fullscreen */}
|
||
<button type="button" onClick={toggleFullscreen} className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20" aria-label="تمامصفحه (F)" title="تمامصفحه (F)">
|
||
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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-right 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>
|
||
);
|
||
}
|