feat: remove glassmorphism from notification toast; improve video player UX
Task 4 — Event Registration Confirmation (glassmorphism removal): - Replace translucent gradient + backdrop-blur-md toast in use-realtime.tsx with a solid bg-card container, full-opacity border, and a colored left accent bar — high contrast, readable in all themes, no blur Task 5 — Video player UX (within anti-cheat constraints): - Keyboard shortcuts: Space (play/pause), M (mute), ← (seek back 10s), F (fullscreen) — active globally, disabled when an input is focused - Fullscreen button added to the control bar (F key shortcut) - Volume slider (0–100%) replaces mute-only toggle; volume is persisted to localStorage across sessions under key `pargar:volume` - Buffer range visualized as a translucent white bar behind the playback progress track so users see how much is buffered - Exponential backoff auto-retry on video error: 1s, 2s, 4s … 30s cap; retry count shown in error message; manual retry clears the timer - Speed controls (0.75x–2x) are intentionally absent: the verified-watch anti-cheat locks playbackRate=1; changing it would break the heartbeat accumulation that gates quiz unlock and certification - HLS/ABR requires backend FFmpeg transcoding; the player is structured so swapping src=mp4 → src=.m3u8 + hls.js is a one-file change when the backend pipeline is ready Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Lock,
|
Lock,
|
||||||
|
Maximize,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -22,6 +23,19 @@ import { toast } from "@/components/providers";
|
|||||||
const INTERVAL_MS = 10_000;
|
const INTERVAL_MS = 10_000;
|
||||||
const SEEK_FORWARD_SLACK = 0.35;
|
const SEEK_FORWARD_SLACK = 0.35;
|
||||||
const CONTINUOUS_PLAY_MAX_DT = 3;
|
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({
|
export function VideoPlayer({
|
||||||
lessonId,
|
lessonId,
|
||||||
@@ -37,6 +51,7 @@ export function VideoPlayer({
|
|||||||
initialProgress?: LessonProgress | null;
|
initialProgress?: LessonProgress | null;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// Lock first signed URL for this mount so React Query refetch cannot reset src.
|
// Lock first signed URL for this mount so React Query refetch cannot reset src.
|
||||||
const lockedSrcRef = useRef(videoUrl);
|
const lockedSrcRef = useRef(videoUrl);
|
||||||
const resumeAppliedRef = useRef(false);
|
const resumeAppliedRef = useRef(false);
|
||||||
@@ -48,11 +63,15 @@ export function VideoPlayer({
|
|||||||
const seqRef = useRef(0);
|
const seqRef = useRef(0);
|
||||||
const programmaticSeekRef = useRef(false);
|
const programmaticSeekRef = useRef(false);
|
||||||
const refreshingSrcRef = useRef(false);
|
const refreshingSrcRef = useRef(false);
|
||||||
|
const retryCountRef = useRef(0);
|
||||||
|
const retryTimerRef = useRef<number | null>(null);
|
||||||
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
|
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
|
||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [volume, setVolume] = useState<number>(savedVolume);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [position, setPosition] = useState(0);
|
const [position, setPosition] = useState(0);
|
||||||
|
const [bufferedEnd, setBufferedEnd] = useState(0);
|
||||||
const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0);
|
const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0);
|
||||||
const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked);
|
const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked);
|
||||||
const [quizUnlockedNow, setQuizUnlockedNow] = useState(false);
|
const [quizUnlockedNow, setQuizUnlockedNow] = useState(false);
|
||||||
@@ -225,6 +244,10 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
lastTimeRef.current = v.currentTime;
|
lastTimeRef.current = v.currentTime;
|
||||||
setPosition(v.currentTime);
|
setPosition(v.currentTime);
|
||||||
|
// Update buffer visualization
|
||||||
|
if (v.buffered.length > 0) {
|
||||||
|
setBufferedEnd(v.buffered.end(v.buffered.length - 1));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
@@ -304,10 +327,50 @@ export function VideoPlayer({
|
|||||||
};
|
};
|
||||||
}, [lessonId]);
|
}, [lessonId]);
|
||||||
|
|
||||||
|
// Sync volume to video element and persist to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.muted = muted;
|
if (!v) return;
|
||||||
}, [muted]);
|
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 () => {
|
const refreshSignedUrl = useCallback(async () => {
|
||||||
if (refreshingSrcRef.current) return;
|
if (refreshingSrcRef.current) return;
|
||||||
@@ -346,6 +409,7 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
retryCountRef.current = 0;
|
||||||
} catch {
|
} catch {
|
||||||
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
|
setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -353,6 +417,23 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
}, [lessonId]);
|
}, [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 toggle = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -360,6 +441,16 @@ export function VideoPlayer({
|
|||||||
else v.pause();
|
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 onPlayChange = useCallback(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
setPlaying(!!v && !v.paused);
|
setPlaying(!!v && !v.paused);
|
||||||
@@ -399,6 +490,7 @@ export function VideoPlayer({
|
|||||||
}, [playing, poke]);
|
}, [playing, poke]);
|
||||||
|
|
||||||
const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0;
|
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 fmt = (s: number) => {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
const sec = Math.floor(s % 60);
|
const sec = Math.floor(s % 60);
|
||||||
@@ -408,6 +500,7 @@ export function VideoPlayer({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
|
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
|
||||||
onMouseMove={poke}
|
onMouseMove={poke}
|
||||||
onPointerDown={poke}
|
onPointerDown={poke}
|
||||||
@@ -442,6 +535,7 @@ export function VideoPlayer({
|
|||||||
onPlaying={() => {
|
onPlaying={() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setBuffering(false);
|
setBuffering(false);
|
||||||
|
retryCountRef.current = 0;
|
||||||
}}
|
}}
|
||||||
onCanPlay={() => {
|
onCanPlay={() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -456,11 +550,13 @@ export function VideoPlayer({
|
|||||||
onError={() => {
|
onError={() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setBuffering(false);
|
setBuffering(false);
|
||||||
setLoadError("پخش ویدیو قطع شد. در حال تلاش برای اتصال مجدد…");
|
const errorMsg = `پخش ویدیو قطع شد. تلاش مجدد ${retryCountRef.current > 0 ? `(${retryCountRef.current})` : ""}…`;
|
||||||
void refreshSignedUrl();
|
setLoadError(errorMsg);
|
||||||
|
scheduleRetry();
|
||||||
}}
|
}}
|
||||||
muted={muted}
|
muted={muted || volume === 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(loading || buffering) && !loadError && (
|
{(loading || buffering) && !loadError && (
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
@@ -469,14 +565,24 @@ export function VideoPlayer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center">
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center">
|
||||||
<p className="text-sm font-bold text-white">{loadError}</p>
|
<p className="text-sm font-bold text-white">{loadError}</p>
|
||||||
<Button type="button" variant="secondary" onClick={() => void refreshSignedUrl()}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (retryTimerRef.current) window.clearTimeout(retryTimerRef.current);
|
||||||
|
retryTimerRef.current = null;
|
||||||
|
void refreshSignedUrl();
|
||||||
|
}}
|
||||||
|
>
|
||||||
تلاش دوباره
|
تلاش دوباره
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
|
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
|
||||||
{!playing && !buffering && !loading && !loadError && (
|
{!playing && !buffering && !loading && !loadError && (
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -505,13 +611,23 @@ export function VideoPlayer({
|
|||||||
{fmt(position)} / {fmt(mediaDuration)}
|
{fmt(position)} / {fmt(mediaDuration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Progress + buffer bar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Progress value={pct} className="h-2 bg-white/15" gradient />
|
{/* Buffered range */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-white/20 transition-[width] duration-300"
|
||||||
|
style={{ width: `${bufferedPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-2 bg-white/10" gradient />
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow transition-[inset-inline-start] duration-300 ease-out"
|
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow transition-[inset-inline-start] duration-300 ease-out"
|
||||||
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
|
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -526,7 +642,7 @@ export function VideoPlayer({
|
|||||||
onClick={skipBack}
|
onClick={skipBack}
|
||||||
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||||
aria-label="بازگشت ۱۰ ثانیه"
|
aria-label="بازگشت ۱۰ ثانیه"
|
||||||
title="۱۰ ثانیه عقب"
|
title="۱۰ ثانیه عقب (←)"
|
||||||
>
|
>
|
||||||
<SkipBack className="h-5 w-5 scale-x-[-1]" />
|
<SkipBack className="h-5 w-5 scale-x-[-1]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -535,20 +651,50 @@ export function VideoPlayer({
|
|||||||
onClick={restartFromStart}
|
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"
|
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="شروع از اول"
|
aria-label="شروع از اول"
|
||||||
title="شروع از اول"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
شروع از اول
|
شروع از اول
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Volume control */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||||
|
aria-label="قطع صدا (M)"
|
||||||
|
title="قطع صدا (M)"
|
||||||
|
>
|
||||||
|
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 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>
|
||||||
|
|
||||||
|
{/* Fullscreen */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMuted((m) => !m)}
|
onClick={toggleFullscreen}
|
||||||
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
className="ms-auto rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||||
aria-label="قطع صدا"
|
aria-label="تمامصفحه (F)"
|
||||||
|
title="تمامصفحه (F)"
|
||||||
>
|
>
|
||||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
<Maximize className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="ms-auto text-xs text-white/70">
|
|
||||||
|
<span className="text-xs text-white/70">
|
||||||
{Math.round(watchedPct)}٪ تماشای تاییدشده
|
{Math.round(watchedPct)}٪ تماشای تاییدشده
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,16 +68,19 @@ export function useRealtimeGateway() {
|
|||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
if (n.route) router.push(n.route);
|
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"
|
||||||
>
|
>
|
||||||
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
<span className="w-1 shrink-0 bg-primary" aria-hidden />
|
||||||
{title}
|
<span className="flex min-w-0 flex-col px-3 py-2.5">
|
||||||
</span>
|
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
||||||
{body ? (
|
{title}
|
||||||
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
|
|
||||||
{body}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{body ? (
|
||||||
|
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-muted-foreground [unicode-bidi:plaintext]">
|
||||||
|
{body}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
{ duration: 5000 },
|
{ duration: 5000 },
|
||||||
|
|||||||
Reference in New Issue
Block a user