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:
ahp
2026-10-18 10:40:13 +03:30
co-authored by Claude Sonnet 4.6
parent 40fa0b8581
commit 602d3f2f4d
2 changed files with 171 additions and 22 deletions
+160 -14
View File
@@ -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<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(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<number | null>(null);
const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise<void>>(async () => undefined);
const [playing, setPlaying] = useState(false);
const [volume, setVolume] = useState<number>(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 (
<div className="space-y-4">
<div
ref={containerRef}
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
onMouseMove={poke}
onPointerDown={poke}
@@ -442,6 +535,7 @@ export function VideoPlayer({
onPlaying={() => {
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 && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="flex flex-col items-center gap-3">
@@ -469,14 +565,24 @@ export function VideoPlayer({
</div>
</div>
)}
{loadError && (
<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>
<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>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
{!playing && !buffering && !loading && !loadError && (
<motion.button
@@ -505,13 +611,23 @@ export function VideoPlayer({
{fmt(position)} / {fmt(mediaDuration)}
</span>
</div>
{/* Progress + buffer bar */}
<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
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)` }}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
@@ -526,7 +642,7 @@ export function VideoPlayer({
onClick={skipBack}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="بازگشت ۱۰ ثانیه"
title="۱۰ ثانیه عقب"
title="۱۰ ثانیه عقب (←)"
>
<SkipBack className="h-5 w-5 scale-x-[-1]" />
</button>
@@ -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="شروع از اول"
>
<RotateCcw className="h-4 w-4" />
شروع از اول
</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
type="button"
onClick={() => setMuted((m) => !m)}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="قطع صدا"
onClick={toggleFullscreen}
className="ms-auto rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
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>
<span className="ms-auto text-xs text-white/70">
<span className="text-xs text-white/70">
{Math.round(watchedPct)}٪ تماشای تاییدشده
</span>
</div>