Files
pargar/frontend/src/components/video-player.tsx
T
ahp f6ce2bd340 refactor(video-player): LTR timeline, unified buffer/progress layers, controls layout, remove ambient
- 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
2026-10-20 17:51:23 +03:30

814 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}