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
This commit is contained in:
ahp
2026-10-20 17:51:23 +03:30
parent 96b10c9148
commit f6ce2bd340
+41 -178
View File
@@ -10,13 +10,10 @@ import {
Minimize,
Pause,
Play,
RotateCcw,
Settings,
SkipBack,
Rewind,
Volume1,
Volume2,
VolumeX,
Zap,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import type Hls from "hls.js";
@@ -35,8 +32,6 @@ const CONTINUOUS_PLAY_MAX_DT = 3;
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
type Speed = (typeof ALLOWED_SPEEDS)[number];
const MAX_RETRY_DELAY_MS = 30_000;
const AMBIENT_FPS = 8;
const AMBIENT_INTERVAL = 1000 / AMBIENT_FPS;
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 60;
@@ -54,7 +49,6 @@ function writeStorage(key: string, value: unknown) {
}
const isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";
// ─── Main Component ───────────────────────────────────────────────────────────
export function VideoPlayer({
@@ -73,9 +67,6 @@ export function VideoPlayer({
// ── Refs ────────────────────────────────────────────────────────────────────
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ambientCanvasRef = useRef<HTMLCanvasElement>(null);
const ambientRafRef = useRef(0);
const ambientLastTickRef = useRef(0);
const hlsRef = useRef<Hls | null>(null);
const lockedSrcRef = useRef(videoUrl);
@@ -98,7 +89,6 @@ export function VideoPlayer({
// ── Persisted prefs ─────────────────────────────────────────────────────────
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
const [ambientMode, setAmbientMode] = useState(() => readStorage("pargar:ambient", true, isBoolean));
const [speed, setSpeed] = useState<Speed>(() => {
const s = readStorage("pargar:speed", 1, isNumber);
return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1;
@@ -121,12 +111,9 @@ export function VideoPlayer({
const [mediaDuration, setMediaDuration] = useState(duration);
const [fullscreen, setFullscreen] = useState(false);
const [theaterMode, setTheaterMode] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
const hideTimer = useRef<number | null>(null);
const settingsPanelRef = useRef<HTMLDivElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
// ── Utilities ───────────────────────────────────────────────────────────────
const warnSeekBlocked = useCallback(() => {
@@ -209,12 +196,6 @@ export function VideoPlayer({
changeSpeed(next);
}, [changeSpeed]);
const toggleAmbient = useCallback(() => {
setAmbientMode((prev) => {
writeStorage("pargar:ambient", !prev);
return !prev;
});
}, []);
// ── HLS init ────────────────────────────────────────────────────────────────
useEffect(() => {
@@ -445,25 +426,6 @@ export function VideoPlayer({
return () => document.removeEventListener("fullscreenchange", onFsChange);
}, []);
// ── Ambient mode rAF loop ───────────────────────────────────────────────────
useEffect(() => {
if (!ambientMode) { cancelAnimationFrame(ambientRafRef.current); return; }
const tick = (now: DOMHighResTimeStamp) => {
ambientRafRef.current = requestAnimationFrame(tick);
if (now - ambientLastTickRef.current < AMBIENT_INTERVAL) return;
ambientLastTickRef.current = now;
const v = videoRef.current;
const canvas = ambientCanvasRef.current;
if (!v || !canvas || v.readyState < 2 || v.paused) return;
try {
const ctx = canvas.getContext("2d");
if (ctx) ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
} catch { setAmbientMode(false); } // CORS taint
};
ambientRafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(ambientRafRef.current);
}, [ambientMode, playing]);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -492,19 +454,6 @@ export function VideoPlayer({
return () => window.removeEventListener("keydown", onKey);
}, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]);
// ── Settings click-outside close ────────────────────────────────────────────
useEffect(() => {
if (!settingsOpen) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (settingsPanelRef.current?.contains(target)) return;
if (settingsBtnRef.current?.contains(target)) return;
setSettingsOpen(false);
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [settingsOpen]);
// ── Cleanup timers ──────────────────────────────────────────────────────────
useEffect(() => {
return () => {
@@ -611,8 +560,8 @@ export function VideoPlayer({
<div
className={cn(
"space-y-4",
// Theater mode: break out of the page's max-w container with a full-bleed trick
theaterMode && "relative left-1/2 right-1/2 -mx-[50vw] w-screen",
// 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 ─── */}
@@ -627,24 +576,6 @@ export function VideoPlayer({
onMouseMove={poke}
onPointerDown={handlePointerDown}
>
{/* Ambient canvas (behind video) */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden>
<canvas
ref={ambientCanvasRef}
width={32}
height={18}
className={cn(
"absolute inset-[-15%] h-[130%] w-[130%] transition-opacity duration-1000",
ambientMode && playing ? "opacity-100" : "opacity-0",
)}
style={{
filter: "blur(55px) saturate(1.4)",
transform: "translate3d(0,0,0)",
willChange: "filter, transform",
}}
/>
</div>
{/* Video element */}
<video
ref={videoRef}
@@ -716,7 +647,7 @@ export function VideoPlayer({
</AnimatePresence>
</div>
{/* Seek indicator (double-tap feedback) */}
{/* Seek indicator (rewind feedback — physical right side) */}
<AnimatePresence>
{seekIndicator && (
<motion.div
@@ -724,10 +655,10 @@ export function VideoPlayer({
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
className="pointer-events-none absolute start-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1"
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">
<SkipBack className="h-6 w-6 text-white" />
<Rewind className="h-6 w-6 text-white" />
</div>
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
</motion.div>
@@ -749,159 +680,91 @@ export function VideoPlayer({
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
</div>
{/* Progress bar with hover tooltip */}
{/* Timeline — unified dir="ltr" coordinate space */}
<div
className="relative mx-4 cursor-pointer"
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-8 z-10 rounded bg-black/80 px-1.5 py-0.5 text-[11px] font-bold text-white"
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>
)}
{/* Buffer track */}
<div className="absolute inset-0 overflow-hidden rounded-full">
{/* 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="h-full rounded-full bg-white/20 transition-[width] duration-300"
className="absolute left-0 top-0 h-full rounded-full bg-white/25 transition-[width] duration-300"
style={{ width: `${bufferedPct}%` }}
/>
</div>
<Progress value={pct} className="h-2 bg-white/10" gradient />
{/* Playhead thumb */}
{/* Play progress layer */}
<div
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow-md transition-[inset-inline-start] duration-150"
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
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 */}
<div className="mt-3 flex flex-wrap items-center gap-1.5 px-4 pb-3">
{/* 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="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="پخش / توقف (Space)">
<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>
{/* Skip back */}
<button type="button" onClick={() => { skipBack(); setSeekIndicator("back"); setTimeout(() => setSeekIndicator(null), 700); }} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="بازگشت ۱۰ ثانیه (J)" title="۱۰ ثانیه عقب (J/←)">
<SkipBack className="h-5 w-5 scale-x-[-1]" />
</button>
{/* Restart */}
<button type="button" onClick={() => { flushAcc(); seekProgrammatic(0); void videoRef.current?.play().catch(() => undefined); poke(); }} className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]" aria-label="شروع از اول">
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">شروع از اول</span>
{/* 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 */}
<div className="flex items-center gap-1.5">
<button type="button" onClick={() => setMuted((m) => !m)} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
<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="h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100" aria-label="میزان صدا" />
</div>
<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 — click cycles through speeds */}
{/* Speed pill */}
<button
type="button"
onClick={cycleSpeed}
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]"
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>
{/* Spacer */}
<span className="flex-1" />
{/* Watched pct */}
{/* 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>
{/* Settings gear — opens ambient / global settings only */}
<button
ref={settingsBtnRef}
type="button"
onClick={() => setSettingsOpen((o) => !o)}
className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center"
title="تنظیمات پخش"
aria-expanded={settingsOpen}
>
<Settings className={cn("h-5 w-5 transition-transform duration-200", settingsOpen && "rotate-45")} />
</button>
{/* Theater mode */}
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] sm:flex items-center justify-center" title="حالت تئاتر">
<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="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="تمام‌صفحه (F)" title="تمام‌صفحه (F)">
<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>
{/* Settings flyout — anchored above the controls bar, closes only on outside click */}
<AnimatePresence>
{settingsOpen && (
<motion.div
ref={settingsPanelRef}
key="settings"
initial={{ opacity: 0, y: 6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.97 }}
transition={{ duration: 0.13 }}
className="absolute bottom-[4.5rem] end-3 z-40 min-w-[200px] rounded-xl border border-white/10 bg-neutral-900/95 p-2 text-sm text-white shadow-xl backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
>
<p className="mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-white/40">تنظیمات پخش</p>
{/* Speed picker (full grid) */}
<p className="mb-1 px-2 text-[11px] font-semibold text-white/60">سرعت</p>
<div className="mb-2 grid grid-cols-3 gap-1">
{ALLOWED_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => { changeSpeed(s); setSettingsOpen(false); }}
className={cn(
"rounded-lg py-1.5 text-xs font-bold transition-colors",
speed === s
? "bg-primary text-primary-foreground"
: "bg-white/10 hover:bg-white/20",
)}
>
{s}×
</button>
))}
</div>
<div className="my-1.5 border-t border-white/10" />
{/* Ambient toggle */}
<button
type="button"
onClick={toggleAmbient}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-2 text-xs font-bold transition-colors",
ambientMode ? "bg-primary/20 text-primary" : "hover:bg-white/10",
)}
>
<Zap className="h-4 w-4 shrink-0" />
<span>حالت محیطی</span>
<span className={cn("ms-auto text-[10px]", ambientMode ? "text-primary" : "text-white/40")}>
{ambientMode ? "فعال" : "غیرفعال"}
</span>
</button>
</motion.div>
)}
</AnimatePresence>
{/* Quiz unlock CTA */}
<AnimatePresence>
{quizUnlockedNow && (
@@ -933,7 +796,7 @@ export function VideoPlayer({
</div>
<div className="w-40 shrink-0">
<Progress value={watchedPct} gradient smooth />
<p className="mt-1 text-end text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
<p className="mt-1 text-right text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
</div>
{quizUnlocked ? (
<Button asChild variant="gradient">