diff --git a/frontend/src/components/video-player.tsx b/frontend/src/components/video-player.tsx index 8637dd0..b7e4ea5 100644 --- a/frontend/src/components/video-player.tsx +++ b/frontend/src/components/video-player.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Lock, + Maximize, Pause, Play, RotateCcw, @@ -22,6 +23,19 @@ import { toast } from "@/components/providers"; const INTERVAL_MS = 10_000; const SEEK_FORWARD_SLACK = 0.35; const CONTINUOUS_PLAY_MAX_DT = 3; +const VOLUME_KEY = "pargar:volume"; +const MAX_RETRY_DELAY_MS = 30_000; + +function savedVolume(): number { + try { + const v = localStorage.getItem(VOLUME_KEY); + if (v !== null) { + const n = parseFloat(v); + if (Number.isFinite(n) && n >= 0 && n <= 1) return n; + } + } catch { /* ignore */ } + return 1; +} export function VideoPlayer({ lessonId, @@ -37,6 +51,7 @@ export function VideoPlayer({ initialProgress?: LessonProgress | null; }) { const videoRef = useRef(null); + const containerRef = useRef(null); // Lock first signed URL for this mount so React Query refetch cannot reset src. const lockedSrcRef = useRef(videoUrl); const resumeAppliedRef = useRef(false); @@ -48,11 +63,15 @@ export function VideoPlayer({ const seqRef = useRef(0); const programmaticSeekRef = useRef(false); const refreshingSrcRef = useRef(false); + const retryCountRef = useRef(0); + const retryTimerRef = useRef(null); const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise>(async () => undefined); const [playing, setPlaying] = useState(false); + const [volume, setVolume] = useState(savedVolume); const [muted, setMuted] = useState(false); const [position, setPosition] = useState(0); + const [bufferedEnd, setBufferedEnd] = useState(0); const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0); const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked); const [quizUnlockedNow, setQuizUnlockedNow] = useState(false); @@ -225,6 +244,10 @@ export function VideoPlayer({ } lastTimeRef.current = v.currentTime; setPosition(v.currentTime); + // Update buffer visualization + if (v.buffered.length > 0) { + setBufferedEnd(v.buffered.end(v.buffered.length - 1)); + } }; const onEnded = () => { @@ -304,10 +327,50 @@ export function VideoPlayer({ }; }, [lessonId]); + // Sync volume to video element and persist to localStorage useEffect(() => { const v = videoRef.current; - if (v) v.muted = muted; - }, [muted]); + if (!v) return; + v.volume = volume; + v.muted = muted || volume === 0; + try { localStorage.setItem(VOLUME_KEY, String(volume)); } catch { /* ignore */ } + }, [volume, muted]); + + // Keyboard shortcuts (active only when container is hovered/focused) + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const onKey = (e: KeyboardEvent) => { + // Only intercept when inside the player area or no input is focused + const active = document.activeElement; + if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT")) return; + + const v = videoRef.current; + if (!v) return; + + if (e.code === "Space") { + e.preventDefault(); + if (v.paused) void v.play().catch(() => undefined); + else v.pause(); + } else if (e.code === "KeyM") { + e.preventDefault(); + setMuted((m) => !m); + } else if (e.code === "ArrowLeft") { + // Only backward seek is permitted (anti-cheat) + e.preventDefault(); + flushAcc(); + seekProgrammatic(Math.max(0, v.currentTime - 10)); + poke(); + } else if (e.code === "KeyF") { + e.preventDefault(); + toggleFullscreen(); + } + }; + + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [flushAcc, seekProgrammatic]); // eslint-disable-line react-hooks/exhaustive-deps const refreshSignedUrl = useCallback(async () => { if (refreshingSrcRef.current) return; @@ -346,6 +409,7 @@ export function VideoPlayer({ } setLoadError(null); setLoading(true); + retryCountRef.current = 0; } catch { setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید."); } finally { @@ -353,6 +417,23 @@ export function VideoPlayer({ } }, [lessonId]); + // Exponential backoff auto-retry on error + 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); + }, [refreshSignedUrl]); + + useEffect(() => { + return () => { + if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current); + }; + }, []); + const toggle = () => { const v = videoRef.current; if (!v) return; @@ -360,6 +441,16 @@ export function VideoPlayer({ else v.pause(); }; + const toggleFullscreen = () => { + const el = containerRef.current; + if (!el) return; + if (!document.fullscreenElement) { + void el.requestFullscreen().catch(() => undefined); + } else { + void document.exitFullscreen().catch(() => undefined); + } + }; + const onPlayChange = useCallback(() => { const v = videoRef.current; setPlaying(!!v && !v.paused); @@ -399,6 +490,7 @@ export function VideoPlayer({ }, [playing, poke]); const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0; + const bufferedPct = mediaDuration > 0 ? Math.min(100, (bufferedEnd / mediaDuration) * 100) : 0; const fmt = (s: number) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); @@ -408,6 +500,7 @@ export function VideoPlayer({ return (
{ setLoading(false); setBuffering(false); + retryCountRef.current = 0; }} onCanPlay={() => { setLoading(false); @@ -456,11 +550,13 @@ export function VideoPlayer({ onError={() => { setLoading(false); setBuffering(false); - setLoadError("پخش ویدیو قطع شد. در حال تلاش برای اتصال مجدد…"); - void refreshSignedUrl(); + const errorMsg = `پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`; + setLoadError(errorMsg); + scheduleRetry(); }} - muted={muted} + muted={muted || volume === 0} /> + {(loading || buffering) && !loadError && (
@@ -469,14 +565,24 @@ export function VideoPlayer({
)} + {loadError && (

{loadError}

-
)} +
{!playing && !buffering && !loading && !loadError && (
+ + {/* Progress + buffer bar */}
- + {/* Buffered range */} +
+
+
+
+
@@ -535,20 +651,50 @@ export function VideoPlayer({ onClick={restartFromStart} 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" aria-label="شروع از اول" - title="شروع از اول" > شروع از اول + + {/* Volume control */} +
+ + { + 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="میزان صدا" + /> +
+ + {/* Fullscreen */} - + + {Math.round(watchedPct)}٪ تماشای تاییدشده
diff --git a/frontend/src/hooks/use-realtime.tsx b/frontend/src/hooks/use-realtime.tsx index e99ed19..e079f71 100644 --- a/frontend/src/hooks/use-realtime.tsx +++ b/frontend/src/hooks/use-realtime.tsx @@ -68,16 +68,19 @@ export function useRealtimeGateway() { toast.dismiss(id); if (n.route) router.push(n.route); }} - className="w-[min(22rem,calc(100vw-1.5rem))] rounded-xl border border-primary/40 bg-gradient-to-br from-primary/25 via-primary/12 to-accent/20 px-3 py-2.5 text-start shadow-[0_16px_40px_-16px_color-mix(in_srgb,var(--color-primary)_75%,transparent)] backdrop-blur-md" + className="flex w-[min(22rem,calc(100vw-1.5rem))] items-stretch overflow-hidden rounded-xl border border-border bg-card text-start shadow-lg" > - - {title} - - {body ? ( - - {body} + + + + {title} - ) : null} + {body ? ( + + {body} + + ) : null} + ), { duration: 5000 },