2026-10-07 19:40:26 +03:30
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
|
import {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
ChevronDown,
|
|
|
|
|
|
Gauge,
|
2026-10-07 19:40:26 +03:30
|
|
|
|
Lock,
|
2026-10-18 10:40:13 +03:30
|
|
|
|
Maximize,
|
2026-10-18 11:10:11 +03:30
|
|
|
|
Minimize,
|
2026-10-07 19:40:26 +03:30
|
|
|
|
Pause,
|
|
|
|
|
|
Play,
|
2026-10-20 17:51:23 +03:30
|
|
|
|
Rewind,
|
2026-10-18 11:10:11 +03:30
|
|
|
|
Volume1,
|
2026-10-07 19:40:26 +03:30
|
|
|
|
Volume2,
|
|
|
|
|
|
VolumeX,
|
|
|
|
|
|
} from "lucide-react";
|
2026-10-18 11:10:11 +03:30
|
|
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
|
|
import type Hls from "hls.js";
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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";
|
2026-10-18 11:10:11 +03:30
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const INTERVAL_MS = 10_000;
|
|
|
|
|
|
const SEEK_FORWARD_SLACK = 0.35;
|
|
|
|
|
|
const CONTINUOUS_PLAY_MAX_DT = 3;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
|
|
|
|
|
type Speed = (typeof ALLOWED_SPEEDS)[number];
|
2026-10-18 10:40:13 +03:30
|
|
|
|
const MAX_RETRY_DELAY_MS = 30_000;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const DOUBLE_TAP_MS = 300;
|
|
|
|
|
|
const DOUBLE_TAP_SLOP = 60;
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ─── localStorage helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
function readStorage<T>(key: string, fallback: T, guard: (v: unknown) => v is T): T {
|
2026-10-18 10:40:13 +03:30
|
|
|
|
try {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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 */ }
|
2026-10-18 10:40:13 +03:30
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
export function VideoPlayer({
|
|
|
|
|
|
lessonId,
|
|
|
|
|
|
videoUrl,
|
|
|
|
|
|
duration,
|
|
|
|
|
|
completionThresholdPct,
|
|
|
|
|
|
initialProgress,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
lessonId: number;
|
|
|
|
|
|
videoUrl: string;
|
|
|
|
|
|
duration: number;
|
|
|
|
|
|
completionThresholdPct: number;
|
|
|
|
|
|
initialProgress?: LessonProgress | null;
|
|
|
|
|
|
}) {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Refs ────────────────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
2026-10-18 10:40:13 +03:30
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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);
|
2026-10-18 10:40:13 +03:30
|
|
|
|
const retryCountRef = useRef(0);
|
|
|
|
|
|
const retryTimerRef = useRef<number | null>(null);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const userSpeedRef = useRef<Speed>(1);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// 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 ──────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const [playing, setPlaying] = useState(false);
|
|
|
|
|
|
const [muted, setMuted] = useState(false);
|
|
|
|
|
|
const [position, setPosition] = useState(0);
|
2026-10-18 10:40:13 +03:30
|
|
|
|
const [bufferedEnd, setBufferedEnd] = useState(0);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const [hoverTime, setHoverTime] = useState<number | null>(null);
|
|
|
|
|
|
const [hoverX, setHoverX] = useState(0);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
|
|
|
const [theaterMode, setTheaterMode] = useState(false);
|
|
|
|
|
|
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const hideTimer = useRef<number | null>(null);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-10-18 12:09:30 +03:30
|
|
|
|
const cycleSpeed = useCallback(() => {
|
|
|
|
|
|
const idx = ALLOWED_SPEEDS.indexOf(userSpeedRef.current);
|
|
|
|
|
|
const next = ALLOWED_SPEEDS[(idx + 1) % ALLOWED_SPEEDS.length];
|
|
|
|
|
|
changeSpeed(next);
|
|
|
|
|
|
}, [changeSpeed]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
|
|
|
|
|
|
// ── 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 ─────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
resumeAppliedRef.current = false;
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { position: p, lastSeq } = await api<{ position: number; lastSeq?: number }>(
|
|
|
|
|
|
`/api/lessons/${lessonId}/resume`
|
|
|
|
|
|
);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (typeof lastSeq === "number" && lastSeq > seqRef.current) seqRef.current = lastSeq;
|
2026-10-07 19:40:26 +03:30
|
|
|
|
if (cancelled || resumeAppliedRef.current || !(p > 1)) return;
|
|
|
|
|
|
const apply = () => {
|
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
|
if (!v || resumeAppliedRef.current) return;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (v.currentTime >= 1) { resumeAppliedRef.current = true; return; }
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
|
|
|
|
|
resumeAppliedRef.current = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
if (videoRef.current) {
|
|
|
|
|
|
if (videoRef.current.readyState >= 1) apply();
|
|
|
|
|
|
else videoRef.current.addEventListener("loadedmetadata", apply, { once: true });
|
|
|
|
|
|
}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
} catch { /* ignore */ }
|
2026-10-07 19:40:26 +03:30
|
|
|
|
})();
|
2026-10-18 11:10:11 +03:30
|
|
|
|
return () => { cancelled = true; };
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}, [lessonId, duration]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (initialProgress?.lastPosition && initialProgress.lastPosition > 0) {
|
|
|
|
|
|
maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, initialProgress.lastPosition);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [initialProgress?.lastPosition]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── 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("آزمون باز شد! میتوانید آموختههایتان را محک بزنید.");
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const msg = (e as Error)?.message ?? "";
|
|
|
|
|
|
if (msg.includes("locked") || msg.includes("قفل")) { toast.error(msg); return; }
|
|
|
|
|
|
toast.error(toUserError(e, "همگامسازی تماشا قطع شد — اتصال را بررسی کنید"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [lessonId]);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
useEffect(() => { sendHeartbeatRef.current = sendHeartbeat; }, [sendHeartbeat]);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Anti-cheat: rate cap, forward-seek block, PiP block ────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
|
if (!v) return;
|
|
|
|
|
|
|
|
|
|
|
|
v.disablePictureInPicture = true;
|
|
|
|
|
|
v.disableRemotePlayback = true;
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const capRate = () => {
|
|
|
|
|
|
const target = userSpeedRef.current;
|
|
|
|
|
|
if (Math.abs(v.playbackRate - target) > 0.01) {
|
|
|
|
|
|
v.playbackRate = target;
|
|
|
|
|
|
if (v.playbackRate > 2.01) warnSeekBlocked();
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
warnSeekBlocked();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onTimeUpdate = () => {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
capRate();
|
2026-10-07 19:40:26 +03:30
|
|
|
|
if (snapForwardSeek()) return;
|
|
|
|
|
|
const dt = v.currentTime - lastTimeRef.current;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// Accumulate content-time delta (backend credits content coverage)
|
2026-10-07 19:40:26 +03:30
|
|
|
|
if (dt > 0 && dt < CONTINUOUS_PLAY_MAX_DT) {
|
|
|
|
|
|
accRef.current += dt;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (v.currentTime > maxTrustedPosRef.current) maxTrustedPosRef.current = v.currentTime;
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
|
|
|
|
|
lastTimeRef.current = v.currentTime;
|
|
|
|
|
|
setPosition(v.currentTime);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (v.buffered.length > 0) setBufferedEnd(v.buffered.end(v.buffered.length - 1));
|
2026-10-07 19:40:26 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const onEnded = () => {
|
|
|
|
|
|
setPlaying(false);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const delta = accRef.current; accRef.current = 0;
|
2026-10-07 19:40:26 +03:30
|
|
|
|
void sendHeartbeatRef.current(v.currentTime, delta);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const blockPiP = () => {
|
|
|
|
|
|
void (async () => {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
try { if (document.pictureInPictureElement === v) await document.exitPictureInPicture(); }
|
|
|
|
|
|
catch { /* ignore */ }
|
|
|
|
|
|
v.pause(); warnSeekBlocked();
|
2026-10-07 19:40:26 +03:30
|
|
|
|
})();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
v.addEventListener("timeupdate", onTimeUpdate);
|
|
|
|
|
|
v.addEventListener("ended", onEnded);
|
|
|
|
|
|
v.addEventListener("seeking", snapForwardSeek);
|
|
|
|
|
|
v.addEventListener("seeked", snapForwardSeek);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
v.addEventListener("ratechange", capRate);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
v.addEventListener("enterpictureinpicture", blockPiP);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
v.removeEventListener("timeupdate", onTimeUpdate);
|
|
|
|
|
|
v.removeEventListener("ended", onEnded);
|
|
|
|
|
|
v.removeEventListener("seeking", snapForwardSeek);
|
|
|
|
|
|
v.removeEventListener("seeked", snapForwardSeek);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
v.removeEventListener("ratechange", capRate);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
v.removeEventListener("enterpictureinpicture", blockPiP);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [lessonId, warnSeekBlocked]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Periodic heartbeats + visibility flush ──────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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 = () => {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (document.hidden) { sendPending({ evenIfPaused: true }); if (!v.paused) v.pause(); }
|
|
|
|
|
|
if (document.pictureInPictureElement === v) { void document.exitPictureInPicture().catch(() => undefined); v.pause(); }
|
2026-10-07 19:40:26 +03:30
|
|
|
|
};
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Volume / mute sync ──────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const v = videoRef.current;
|
2026-10-18 10:40:13 +03:30
|
|
|
|
if (!v) return;
|
|
|
|
|
|
v.volume = volume;
|
|
|
|
|
|
v.muted = muted || volume === 0;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
writeStorage("pargar:volume", volume);
|
2026-10-18 10:40:13 +03:30
|
|
|
|
}, [volume, muted]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Apply speed on mount and change ────────────────────────────────────────
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
|
if (v) { v.playbackRate = speed; userSpeedRef.current = speed; }
|
|
|
|
|
|
}, [speed]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Fullscreen change listener ──────────────────────────────────────────────
|
2026-10-18 10:40:13 +03:30
|
|
|
|
useEffect(() => {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const onFsChange = () => setFullscreen(!!document.fullscreenElement);
|
|
|
|
|
|
document.addEventListener("fullscreenchange", onFsChange);
|
|
|
|
|
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
|
|
|
|
useEffect(() => {
|
2026-10-18 10:40:13 +03:30
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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;
|
2026-10-18 10:40:13 +03:30
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
}, [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
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
);
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (!data.videoUrl) { setLoadError("ویدیوی این درس در دسترس نیست."); return; }
|
2026-10-07 19:40:26 +03:30
|
|
|
|
lockedSrcRef.current = data.videoUrl;
|
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
|
if (v) {
|
|
|
|
|
|
const keep = v.currentTime;
|
|
|
|
|
|
const wasPlaying = !v.paused;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (hlsRef.current) {
|
|
|
|
|
|
hlsRef.current.loadSource(data.videoUrl);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
v.src = data.videoUrl;
|
|
|
|
|
|
v.load();
|
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const restore = () => {
|
|
|
|
|
|
if (keep > 0) {
|
|
|
|
|
|
programmaticSeekRef.current = true;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
v.currentTime = keep; lastTimeRef.current = keep;
|
|
|
|
|
|
window.setTimeout(() => { programmaticSeekRef.current = false; }, 120);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
|
|
|
|
|
if (wasPlaying) void v.play().catch(() => undefined);
|
|
|
|
|
|
};
|
|
|
|
|
|
v.addEventListener("loadedmetadata", restore, { once: true });
|
|
|
|
|
|
}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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; }
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}, [lessonId]);
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Double-tap detection ────────────────────────────────────────────────────
|
|
|
|
|
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
|
|
|
|
poke();
|
|
|
|
|
|
if (e.pointerType !== "touch") return; // only touch triggers double-tap
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
} else {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
}, [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));
|
2026-10-07 19:40:26 +03:30
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
// ── Derived values ──────────────────────────────────────────────────────────
|
2026-10-07 19:40:26 +03:30
|
|
|
|
const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0;
|
2026-10-18 10:40:13 +03:30
|
|
|
|
const bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / mediaDuration) * 100) : 0;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
const VolumeIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
|
|
|
|
|
return (
|
2026-10-18 12:09:30 +03:30
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"space-y-4",
|
2026-10-20 17:51:23 +03:30
|
|
|
|
// 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",
|
2026-10-18 12:09:30 +03:30
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* ── Player container ─── */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<div
|
2026-10-18 10:40:13 +03:30
|
|
|
|
ref={containerRef}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
className={cn(
|
2026-10-18 12:09:30 +03:30
|
|
|
|
"group relative overflow-hidden bg-black shadow-soft",
|
|
|
|
|
|
theaterMode
|
|
|
|
|
|
? "rounded-none border-y border-border"
|
|
|
|
|
|
: "rounded-xl border border-border",
|
2026-10-18 11:10:11 +03:30
|
|
|
|
)}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
onMouseMove={poke}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
onPointerDown={handlePointerDown}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* Video element */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<video
|
|
|
|
|
|
ref={videoRef}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
className="relative z-10 aspect-video w-full"
|
2026-10-07 19:40:26 +03:30
|
|
|
|
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;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (el) el.playbackRate = userSpeedRef.current;
|
|
|
|
|
|
setLoadError(null); setPlaying(true); poke();
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
onPause={() => { setPlaying(false); poke(); }}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
onWaiting={() => setBuffering(true)}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
onPlaying={() => { setLoading(false); setBuffering(false); retryCountRef.current = 0; }}
|
|
|
|
|
|
onCanPlay={() => { setLoading(false); setBuffering(false); }}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
onLoadedMetadata={() => {
|
|
|
|
|
|
const el = videoRef.current;
|
2026-10-18 11:10:11 +03:30
|
|
|
|
if (el && Number.isFinite(el.duration) && el.duration > 0) setMediaDuration(el.duration);
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}}
|
|
|
|
|
|
onError={() => {
|
2026-10-18 11:10:11 +03:30
|
|
|
|
setLoading(false); setBuffering(false);
|
|
|
|
|
|
setLoadError(`پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`);
|
2026-10-18 10:40:13 +03:30
|
|
|
|
scheduleRetry();
|
2026-10-07 19:40:26 +03:30
|
|
|
|
}}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
muted={muted || volume === 0}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
/>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* Loading spinner */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
{(loading || buffering) && !loadError && (
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/50">
|
|
|
|
|
|
<Skeleton className="h-16 w-16 rounded-full" />
|
2026-10-07 19:40:26 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* Error overlay */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
{loadError && (
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/85 px-6 text-center">
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<p className="text-sm font-bold text-white">{loadError}</p>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<Button type="button" variant="secondary" onClick={() => {
|
|
|
|
|
|
if (retryTimerRef.current) { window.clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
|
|
|
|
|
|
void refreshSignedUrl();
|
|
|
|
|
|
}}>تلاش دوباره</Button>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Seek indicator (rewind feedback — physical right side) */}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{seekIndicator && (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key="seek-indicator"
|
|
|
|
|
|
initial={{ opacity: 0, scale: 0.7 }}
|
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0, scale: 0.7 }}
|
2026-10-20 17:51:23 +03:30
|
|
|
|
className="pointer-events-none absolute right-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
|
2026-10-07 19:40:26 +03:30
|
|
|
|
>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
|
2026-10-20 17:51:23 +03:30
|
|
|
|
<Rewind className="h-6 w-6 text-white" />
|
2026-10-18 11:10:11 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
|
|
|
|
|
|
</motion.div>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
)}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
</AnimatePresence>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* Controls overlay */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<div
|
2026-10-18 11:10:11 +03:30
|
|
|
|
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",
|
|
|
|
|
|
)}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* Status bar */}
|
|
|
|
|
|
<div className="mb-2 flex items-center gap-1 px-4 pt-4 text-[11px] font-medium text-white/90">
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<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>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
</div>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Timeline — unified dir="ltr" coordinate space */}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<div
|
2026-10-20 17:51:23 +03:30
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="relative mx-4 cursor-pointer py-2"
|
2026-10-18 11:10:11 +03:30
|
|
|
|
onMouseMove={onProgressHover}
|
|
|
|
|
|
onMouseLeave={() => setHoverTime(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Hover timestamp tooltip */}
|
|
|
|
|
|
{hoverTime !== null && (
|
|
|
|
|
|
<div
|
2026-10-20 17:51:23 +03:30
|
|
|
|
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"
|
2026-10-18 11:10:11 +03:30
|
|
|
|
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{fmt(hoverTime)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Track layers stacked bottom-to-top: base → buffer → progress → thumb */}
|
|
|
|
|
|
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
|
|
|
|
|
{/* Buffer layer */}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
<div
|
2026-10-20 17:51:23 +03:30
|
|
|
|
className="absolute left-0 top-0 h-full rounded-full bg-white/25 transition-[width] duration-300"
|
2026-10-18 10:40:13 +03:30
|
|
|
|
style={{ width: `${bufferedPct}%` }}
|
|
|
|
|
|
/>
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Play progress layer */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="absolute left-0 top-0 h-full rounded-full bg-primary transition-[width] duration-150"
|
|
|
|
|
|
style={{ width: `${pct}%` }}
|
|
|
|
|
|
/>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
</div>
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Playhead thumb — positioned outside overflow:hidden, same dir="ltr" space */}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<div
|
2026-10-20 17:51:23 +03:30
|
|
|
|
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)` }}
|
2026-10-07 19:40:26 +03:30
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-10-18 10:40:13 +03:30
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* 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" />}
|
2026-10-18 10:40:13 +03:30
|
|
|
|
</button>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* 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>
|
2026-10-18 12:09:30 +03:30
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* 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="میزان صدا" />
|
2026-10-18 12:09:30 +03:30
|
|
|
|
|
2026-10-20 17:51:23 +03:30
|
|
|
|
{/* Speed pill */}
|
2026-10-18 11:10:11 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-10-20 17:51:23 +03:30
|
|
|
|
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"
|
2026-10-18 11:10:11 +03:30
|
|
|
|
>
|
2026-10-20 17:51:23 +03:30
|
|
|
|
<Gauge className="h-4 w-4" />
|
|
|
|
|
|
<span>{speed}×</span>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
</button>
|
2026-10-20 17:51:23 +03:30
|
|
|
|
</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>
|
2026-10-18 11:10:11 +03:30
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-18 11:10:11 +03:30
|
|
|
|
{/* ── Progress strip below player ─── */}
|
2026-10-18 12:09:30 +03:30
|
|
|
|
<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",
|
|
|
|
|
|
)}>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
<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 />
|
2026-10-20 17:51:23 +03:30
|
|
|
|
<p className="mt-1 text-right text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
|
2026-10-07 19:40:26 +03:30
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|