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:
@@ -10,13 +10,10 @@ import {
|
|||||||
Minimize,
|
Minimize,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
Rewind,
|
||||||
Settings,
|
|
||||||
SkipBack,
|
|
||||||
Volume1,
|
Volume1,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import type Hls from "hls.js";
|
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;
|
const ALLOWED_SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
|
||||||
type Speed = (typeof ALLOWED_SPEEDS)[number];
|
type Speed = (typeof ALLOWED_SPEEDS)[number];
|
||||||
const MAX_RETRY_DELAY_MS = 30_000;
|
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_MS = 300;
|
||||||
const DOUBLE_TAP_SLOP = 60;
|
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 isNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||||
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";
|
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
export function VideoPlayer({
|
export function VideoPlayer({
|
||||||
@@ -73,9 +67,6 @@ export function VideoPlayer({
|
|||||||
// ── Refs ────────────────────────────────────────────────────────────────────
|
// ── Refs ────────────────────────────────────────────────────────────────────
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(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 hlsRef = useRef<Hls | null>(null);
|
||||||
|
|
||||||
const lockedSrcRef = useRef(videoUrl);
|
const lockedSrcRef = useRef(videoUrl);
|
||||||
@@ -98,7 +89,6 @@ export function VideoPlayer({
|
|||||||
|
|
||||||
// ── Persisted prefs ─────────────────────────────────────────────────────────
|
// ── Persisted prefs ─────────────────────────────────────────────────────────
|
||||||
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
|
const [volume, setVolume] = useState(() => readStorage("pargar:volume", 1, isNumber));
|
||||||
const [ambientMode, setAmbientMode] = useState(() => readStorage("pargar:ambient", true, isBoolean));
|
|
||||||
const [speed, setSpeed] = useState<Speed>(() => {
|
const [speed, setSpeed] = useState<Speed>(() => {
|
||||||
const s = readStorage("pargar:speed", 1, isNumber);
|
const s = readStorage("pargar:speed", 1, isNumber);
|
||||||
return (ALLOWED_SPEEDS as readonly number[]).includes(s) ? s as Speed : 1;
|
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 [mediaDuration, setMediaDuration] = useState(duration);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [theaterMode, setTheaterMode] = useState(false);
|
const [theaterMode, setTheaterMode] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
const [seekIndicator, setSeekIndicator] = useState<"back" | null>(null);
|
||||||
|
|
||||||
const hideTimer = useRef<number | null>(null);
|
const hideTimer = useRef<number | null>(null);
|
||||||
const settingsPanelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// ── Utilities ───────────────────────────────────────────────────────────────
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
||||||
const warnSeekBlocked = useCallback(() => {
|
const warnSeekBlocked = useCallback(() => {
|
||||||
@@ -209,12 +196,6 @@ export function VideoPlayer({
|
|||||||
changeSpeed(next);
|
changeSpeed(next);
|
||||||
}, [changeSpeed]);
|
}, [changeSpeed]);
|
||||||
|
|
||||||
const toggleAmbient = useCallback(() => {
|
|
||||||
setAmbientMode((prev) => {
|
|
||||||
writeStorage("pargar:ambient", !prev);
|
|
||||||
return !prev;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── HLS init ────────────────────────────────────────────────────────────────
|
// ── HLS init ────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -445,25 +426,6 @@ export function VideoPlayer({
|
|||||||
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Ambient mode rAF loop ───────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ambientMode) { cancelAnimationFrame(ambientRafRef.current); return; }
|
|
||||||
const tick = (now: DOMHighResTimeStamp) => {
|
|
||||||
ambientRafRef.current = requestAnimationFrame(tick);
|
|
||||||
if (now - ambientLastTickRef.current < AMBIENT_INTERVAL) return;
|
|
||||||
ambientLastTickRef.current = now;
|
|
||||||
const v = videoRef.current;
|
|
||||||
const canvas = ambientCanvasRef.current;
|
|
||||||
if (!v || !canvas || v.readyState < 2 || v.paused) return;
|
|
||||||
try {
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (ctx) ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
|
|
||||||
} catch { setAmbientMode(false); } // CORS taint
|
|
||||||
};
|
|
||||||
ambientRafRef.current = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(ambientRafRef.current);
|
|
||||||
}, [ambientMode, playing]);
|
|
||||||
|
|
||||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -492,19 +454,6 @@ export function VideoPlayer({
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]);
|
}, [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 ──────────────────────────────────────────────────────────
|
// ── Cleanup timers ──────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -611,8 +560,8 @@ export function VideoPlayer({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-4",
|
"space-y-4",
|
||||||
// Theater mode: break out of the page's max-w container with a full-bleed trick
|
// Theater mode: on mobile remove page padding; on sm+ break out to full-bleed
|
||||||
theaterMode && "relative left-1/2 right-1/2 -mx-[50vw] w-screen",
|
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 ─── */}
|
{/* ── Player container ─── */}
|
||||||
@@ -627,24 +576,6 @@ export function VideoPlayer({
|
|||||||
onMouseMove={poke}
|
onMouseMove={poke}
|
||||||
onPointerDown={handlePointerDown}
|
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 element */}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -716,7 +647,7 @@ export function VideoPlayer({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seek indicator (double-tap feedback) */}
|
{/* Seek indicator (rewind feedback — physical right side) */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{seekIndicator && (
|
{seekIndicator && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -724,10 +655,10 @@ export function VideoPlayer({
|
|||||||
initial={{ opacity: 0, scale: 0.7 }}
|
initial={{ opacity: 0, scale: 0.7 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.7 }}
|
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">
|
<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>
|
</div>
|
||||||
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
|
<span className="text-xs font-bold text-white drop-shadow">۱۰ ثانیه</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -749,158 +680,90 @@ export function VideoPlayer({
|
|||||||
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
|
<span className="ms-auto tabular-nums">{fmt(position)} / {fmt(mediaDuration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar with hover tooltip */}
|
{/* Timeline — unified dir="ltr" coordinate space */}
|
||||||
<div
|
<div
|
||||||
className="relative mx-4 cursor-pointer"
|
dir="ltr"
|
||||||
|
className="relative mx-4 cursor-pointer py-2"
|
||||||
onMouseMove={onProgressHover}
|
onMouseMove={onProgressHover}
|
||||||
onMouseLeave={() => setHoverTime(null)}
|
onMouseLeave={() => setHoverTime(null)}
|
||||||
>
|
>
|
||||||
{/* Hover timestamp tooltip */}
|
{/* Hover timestamp tooltip */}
|
||||||
{hoverTime !== null && (
|
{hoverTime !== null && (
|
||||||
<div
|
<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%)" }}
|
style={{ left: `${hoverX}px`, transform: "translateX(-50%)" }}
|
||||||
>
|
>
|
||||||
{fmt(hoverTime)}
|
{fmt(hoverTime)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Buffer track */}
|
{/* Track layers stacked bottom-to-top: base → buffer → progress → thumb */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||||
|
{/* Buffer layer */}
|
||||||
<div
|
<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}%` }}
|
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>
|
</div>
|
||||||
<Progress value={pct} className="h-2 bg-white/10" gradient />
|
{/* Playhead thumb — positioned outside overflow:hidden, same dir="ltr" space */}
|
||||||
{/* Playhead thumb */}
|
|
||||||
<div
|
<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"
|
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={{ insetInlineStart: `calc(${pct}% - 8px)` }}
|
style={{ left: `calc(${pct}% - 8px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom control row */}
|
{/* Bottom control row — dir="ltr" for physical left→right placement */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 px-4 pb-3">
|
<div dir="ltr" className="mt-1 flex items-center justify-between gap-1 px-4 pb-3">
|
||||||
{/* Play/Pause */}
|
{/* Left group: playback controls + volume + speed */}
|
||||||
<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)">
|
<div className="flex items-center gap-1">
|
||||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{/* Play/Pause */}
|
||||||
</button>
|
<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 */}
|
{/* Rewind 10s */}
|
||||||
<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/←)">
|
<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/←)">
|
||||||
<SkipBack className="h-5 w-5 scale-x-[-1]" />
|
<Rewind className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Restart */}
|
{/* Volume */}
|
||||||
<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="شروع از اول">
|
<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)">
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">شروع از اول</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Volume */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button type="button" onClick={() => setMuted((m) => !m)} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="قطع صدا (M)" title="قطع/وصل صدا (M)">
|
|
||||||
<VolumeIcon className="h-5 w-5" />
|
<VolumeIcon className="h-5 w-5" />
|
||||||
</button>
|
</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="میزان صدا" />
|
<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="میزان صدا" />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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]"
|
|
||||||
>
|
|
||||||
<Gauge className="h-4 w-4" />
|
|
||||||
<span>{speed}×</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<span className="flex-1" />
|
|
||||||
|
|
||||||
{/* Watched pct */}
|
|
||||||
<span className="hidden text-xs text-white/70 sm:inline">{Math.round(watchedPct)}٪</span>
|
|
||||||
|
|
||||||
{/* Settings gear — opens ambient / global settings only */}
|
|
||||||
<button
|
|
||||||
ref={settingsBtnRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSettingsOpen((o) => !o)}
|
|
||||||
className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
||||||
title="تنظیمات پخش"
|
|
||||||
aria-expanded={settingsOpen}
|
|
||||||
>
|
|
||||||
<Settings className={cn("h-5 w-5 transition-transform duration-200", settingsOpen && "rotate-45")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Theater mode */}
|
|
||||||
<button type="button" onClick={() => setTheaterMode((t) => !t)} className="hidden rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] sm:flex items-center justify-center" title="حالت تئاتر">
|
|
||||||
<ChevronDown className={cn("h-5 w-5 transition-transform", theaterMode && "rotate-180")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Fullscreen */}
|
|
||||||
<button type="button" onClick={toggleFullscreen} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="تمامصفحه (F)" title="تمامصفحه (F)">
|
|
||||||
{fullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings flyout — anchored above the controls bar, closes only on outside click */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{settingsOpen && (
|
|
||||||
<motion.div
|
|
||||||
ref={settingsPanelRef}
|
|
||||||
key="settings"
|
|
||||||
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 6, scale: 0.97 }}
|
|
||||||
transition={{ duration: 0.13 }}
|
|
||||||
className="absolute bottom-[4.5rem] end-3 z-40 min-w-[200px] rounded-xl border border-white/10 bg-neutral-900/95 p-2 text-sm text-white shadow-xl backdrop-blur-md"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<p className="mb-1.5 px-2 text-[10px] font-bold uppercase tracking-widest text-white/40">تنظیمات پخش</p>
|
|
||||||
|
|
||||||
{/* Speed picker (full grid) */}
|
|
||||||
<p className="mb-1 px-2 text-[11px] font-semibold text-white/60">سرعت</p>
|
|
||||||
<div className="mb-2 grid grid-cols-3 gap-1">
|
|
||||||
{ALLOWED_SPEEDS.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onClick={() => { changeSpeed(s); setSettingsOpen(false); }}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg py-1.5 text-xs font-bold transition-colors",
|
|
||||||
speed === s
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-white/10 hover:bg-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s}×
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="my-1.5 border-t border-white/10" />
|
|
||||||
|
|
||||||
{/* Ambient toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAmbient}
|
onClick={cycleSpeed}
|
||||||
className={cn(
|
title={`سرعت پخش: ${speed}× — کلیک برای تغییر`}
|
||||||
"flex w-full items-center gap-2 rounded-lg px-2 py-2 text-xs font-bold transition-colors",
|
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"
|
||||||
ambientMode ? "bg-primary/20 text-primary" : "hover:bg-white/10",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Zap className="h-4 w-4 shrink-0" />
|
<Gauge className="h-4 w-4" />
|
||||||
<span>حالت محیطی</span>
|
<span>{speed}×</span>
|
||||||
<span className={cn("ms-auto text-[10px]", ambientMode ? "text-primary" : "text-white/40")}>
|
|
||||||
{ambientMode ? "فعال" : "غیرفعال"}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
{/* 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 */}
|
{/* Quiz unlock CTA */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -933,7 +796,7 @@ export function VideoPlayer({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-40 shrink-0">
|
<div className="w-40 shrink-0">
|
||||||
<Progress value={watchedPct} gradient smooth />
|
<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>
|
</div>
|
||||||
{quizUnlocked ? (
|
{quizUnlocked ? (
|
||||||
<Button asChild variant="gradient">
|
<Button asChild variant="gradient">
|
||||||
|
|||||||
Reference in New Issue
Block a user