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 {
|
||||
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="قطع صدا"
|
||||
aria-label="قطع صدا (M)"
|
||||
title="قطع صدا (M)"
|
||||
>
|
||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<span className="ms-auto text-xs text-white/70">
|
||||
<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={toggleFullscreen}
|
||||
className="ms-auto rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||
aria-label="تمامصفحه (F)"
|
||||
title="تمامصفحه (F)"
|
||||
>
|
||||
<Maximize className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-white/70">
|
||||
{Math.round(watchedPct)}٪ تماشای تاییدشده
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="w-1 shrink-0 bg-primary" aria-hidden />
|
||||
<span className="flex min-w-0 flex-col px-3 py-2.5">
|
||||
<span dir="auto" className="block truncate text-sm font-semibold text-foreground [unicode-bidi:plaintext]">
|
||||
{title}
|
||||
</span>
|
||||
{body ? (
|
||||
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-foreground/75 [unicode-bidi:plaintext]">
|
||||
<span dir="auto" className="mt-0.5 block line-clamp-2 text-xs text-muted-foreground [unicode-bidi:plaintext]">
|
||||
{body}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
{ duration: 5000 },
|
||||
|
||||
Reference in New Issue
Block a user