canina/frontend/application/components/VideoModalPlayer.tsx
parsaaghayi bd49fbc4a0
Some checks failed
Deploy Canina / deploy (push) Successful in 2m25s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s
fix(security): remediate audit vulnerabilities SEC-001 through SEC-023
2026-09-23 23:00:25 +03:30

606 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Maximize,
Minimize,
Download,
Share2,
X,
Check,
ChevronDown,
ChevronUp,
Sparkles,
Loader2
} from "lucide-react";
import { getMediaUrl } from "../lib/media";
import DOMPurify from "dompurify";
interface VideoModalPlayerProps {
isOpen: boolean;
onClose: () => void;
video: {
id?: string;
title?: string;
doctor?: string;
videoUrl?: string;
thumbnail?: string;
description?: string;
quote?: string;
} | null;
}
const PLAYBACK_RATES = [0.5, 1, 1.25, 1.5, 1.75, 2, 2.5];
export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const rateMenuRef = useRef<HTMLDivElement>(null);
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedEnd, setBufferedEnd] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
const [showRateMenu, setShowRateMenu] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [showControls, setShowControls] = useState(true);
const [isExpandedDesc, setIsExpandedDesc] = useState(false);
const [isCopied, setIsCopied] = useState(false);
// Close rate menu when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (rateMenuRef.current && !rateMenuRef.current.contains(e.target as Node)) {
setShowRateMenu(false);
}
}
if (showRateMenu) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showRateMenu]);
// Reset / Restore states on open/close
useEffect(() => {
if (isOpen && video?.id) {
const savedTime = localStorage.getItem(`video_time_${video.id}`);
const initialTime = savedTime ? parseFloat(savedTime) : 0;
setIsPlaying(true);
setShowControls(true);
setIsExpandedDesc(false);
setShowRateMenu(false);
setBufferedEnd(0);
if (videoRef.current && initialTime > 0) {
videoRef.current.currentTime = initialTime;
setCurrentTime(initialTime);
}
} else if (!isOpen && video?.id) {
if (currentTime > 0) {
localStorage.setItem(`video_time_${video.id}`, currentTime.toString());
}
setIsPlaying(false);
setBufferedEnd(0);
}
}, [isOpen, video?.id]);
// Save playback time periodically
useEffect(() => {
if (video?.id && currentTime > 0) {
localStorage.setItem(`video_time_${video.id}`, currentTime.toString());
}
}, [currentTime, video?.id]);
// Update buffered track
const updateBuffer = useCallback(() => {
if (videoRef.current && videoRef.current.buffered.length > 0) {
const current = videoRef.current.currentTime;
for (let i = 0; i < videoRef.current.buffered.length; i++) {
if (
videoRef.current.buffered.start(i) <= current &&
videoRef.current.buffered.end(i) >= current
) {
setBufferedEnd(videoRef.current.buffered.end(i));
break;
}
}
}
}, []);
// Handle auto-hide timer for controls
const resetControlsTimer = useCallback(() => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
// If video is playing, auto-hide after 3.5s; if paused, stay visible
if (isPlaying) {
controlsTimeoutRef.current = setTimeout(() => {
if (!showRateMenu) {
setShowControls(false);
}
}, 3500);
}
}, [isPlaying, showRateMenu]);
useEffect(() => {
if (!isPlaying) {
setShowControls(true);
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
} else {
resetControlsTimer();
}
return () => {
if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current);
};
}, [isPlaying, resetControlsTimer]);
const togglePlay = () => {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play().catch(console.error);
setIsPlaying(true);
} else {
videoRef.current.pause();
setIsPlaying(false);
setShowControls(true);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = Number(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
};
const skipTime = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(0, Math.min(videoRef.current.duration || 0, videoRef.current.currentTime + seconds));
resetControlsTimer();
}
};
const handleRateChange = (rate: number) => {
if (videoRef.current) {
videoRef.current.playbackRate = rate;
setPlaybackRate(rate);
setShowRateMenu(false);
resetControlsTimer();
}
};
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setVolume(val);
if (videoRef.current) {
videoRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
videoRef.current.muted = true;
} else {
setIsMuted(false);
videoRef.current.muted = false;
}
}
};
const toggleFullscreen = async () => {
if (!containerRef.current) return;
try {
if (!document.fullscreenElement) {
await containerRef.current.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
} catch (err) {
console.error('Fullscreen toggle failed:', err);
}
};
const handleShare = async () => {
const shareUrl = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: video?.title || 'ویدئوی راهنمای کنینا',
text: video?.description || '',
url: shareUrl
});
} catch {
// Fallback to clipboard
}
} else {
try {
await navigator.clipboard.writeText(shareUrl);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2500);
} catch (err) {
console.error('Copy failed:', err);
}
}
};
const handleDownload = () => {
if (!video?.videoUrl) return;
const a = document.createElement('a');
a.href = video.videoUrl;
a.download = `${video.title || 'canina-video'}.mp4`;
a.target = '_blank';
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const formatTime = (secs: number) => {
if (isNaN(secs) || secs < 0) return "00:00";
const mins = Math.floor(secs / 60);
const remainingSecs = Math.floor(secs % 60);
return `${mins.toString().padStart(2, "0")}:${remainingSecs.toString().padStart(2, "0")}`;
};
if (!isOpen || !video) return null;
const isIframe = video.videoUrl?.includes("<iframe");
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
const bufferedPercent = duration > 0 ? (bufferedEnd / duration) * 100 : 0;
const volumePercent = isMuted ? 0 : volume * 100;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[120] flex items-center justify-center p-3 sm:p-6 font-vazir overflow-y-auto" dir="rtl">
{/* Backdrop (backdrop dismiss disabled to prevent accidental closure) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/90 backdrop-blur-xl"
/>
{/* Modal Window Wrapper */}
<div className="relative w-full max-w-5xl flex flex-col gap-3 my-auto z-10">
<motion.div
ref={containerRef}
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 15 }}
transition={{ duration: 0.25 }}
onMouseMove={resetControlsTimer}
onTouchStart={resetControlsTimer}
className="relative bg-black w-full aspect-video rounded-3xl sm:rounded-[2.5rem] overflow-hidden shadow-2xl border border-white/10 flex flex-col justify-between select-none"
>
{/* Iframe Support (Aparat / Youtube) */}
{isIframe ? (
<div className="w-full h-full flex items-center justify-center relative">
<button
type="button"
onClick={onClose}
className="absolute top-4 left-4 z-30 p-2.5 rounded-full bg-black/60 hover:bg-black/90 text-white transition-all cursor-pointer"
title="بستن"
>
<X className="w-5 h-5" />
</button>
<div
className="w-full h-full [&>iframe]:w-full [&>iframe]:h-full"
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(video.videoUrl || '', {
ALLOWED_TAGS: ['iframe'],
ALLOWED_ATTR: ['src', 'width', 'height', 'frameborder', 'allow', 'allowfullscreen', 'title'],
})
: video.videoUrl || '',
}}
/>
</div>
) : (
<>
{/* Native Video Element */}
<div
className="absolute inset-0 flex items-center justify-center cursor-pointer"
onClick={togglePlay}
>
<video
ref={videoRef}
src={getMediaUrl(video.videoUrl)}
poster={getMediaUrl(video.thumbnail)}
autoPlay
playsInline
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
updateBuffer();
}
}}
onProgress={updateBuffer}
onLoadedMetadata={() => {
if (videoRef.current) setDuration(videoRef.current.duration);
setIsBuffering(false);
updateBuffer();
}}
onWaiting={() => setIsBuffering(true)}
onPlaying={() => setIsBuffering(false)}
onEnded={() => {
setIsPlaying(false);
setShowControls(true);
}}
className="w-full h-full object-contain"
/>
{/* Buffering Spinner */}
{isBuffering && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30 backdrop-blur-2xs pointer-events-none">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
</div>
{/* Top Header Overlay */}
<div
className={`absolute top-0 left-0 right-0 p-4 sm:p-6 bg-gradient-to-b from-black/80 via-black/40 to-transparent transition-opacity duration-300 z-20 flex items-center justify-between ${showControls ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
>
<div className="flex items-center gap-3 text-right">
<div className="w-8 h-8 rounded-full bg-canina-blue/20 text-canina-blue flex items-center justify-center shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h3 className="text-white text-sm sm:text-base md:text-lg font-black line-clamp-1">
{video.title}
</h3>
<p className="text-white/60 text-xs font-bold mt-0.5">
{video.doctor || "کادر علمی کنینا"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Share button */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleShare(); }}
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer relative"
title="اشتراک‌گذاری"
>
{isCopied ? <Check className="w-4 h-4 text-emerald-400" /> : <Share2 className="w-4 h-4" />}
</button>
{/* Download button */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
title="دانلود ویدئو"
>
<Download className="w-4 h-4" />
</button>
{/* Close button */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="p-2.5 rounded-full bg-white/10 hover:bg-red-500/80 text-white transition-all cursor-pointer"
title="بستن"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Bottom Custom Controls Bar */}
<div
className={`absolute bottom-0 left-0 right-0 p-4 sm:p-6 bg-gradient-to-t from-black/90 via-black/60 to-transparent transition-opacity duration-300 z-20 space-y-3 ${showControls ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Progress / Seek Bar with filled progress & buffer tracks */}
<div className="flex items-center gap-3 dir-ltr" dir="ltr">
<span className="text-[11px] font-mono font-bold text-white/70 w-10 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 flex items-center h-4">
{/* Background & Buffer & Progress Track */}
<div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
{/* Buffered bar */}
<div
className="h-full bg-white/35 transition-all duration-300"
style={{ width: `${bufferedPercent}%` }}
/>
{/* Played progress bar */}
<div
className="h-full bg-canina-blue transition-all -mt-1.5"
style={{ width: `${progressPercent}%` }}
/>
</div>
<input
type="range"
min={0}
max={duration || 100}
step={0.1}
value={currentTime}
onChange={handleSeek}
className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
/>
{/* Visual Scrubber Thumb */}
<div
className="absolute w-3.5 h-3.5 bg-white rounded-full shadow-md pointer-events-none -translate-x-1/2 transition-transform hover:scale-125"
style={{ left: `${progressPercent}%` }}
/>
</div>
<span className="text-[11px] font-mono font-bold text-white/70 w-10 text-left">
{formatTime(duration)}
</span>
</div>
{/* Action Buttons Row */}
<div className="flex items-center justify-between" dir="ltr">
{/* Left Controls (Playback & Jumps with - on Left and + on Right in RTL) */}
<div className="flex items-center gap-2 sm:gap-3" dir="ltr">
{/* Play/Pause */}
<button
type="button"
onClick={togglePlay}
className="w-10 h-10 rounded-full bg-canina-blue hover:bg-canina-blue/90 text-white flex items-center justify-center transition-transform hover:scale-105 cursor-pointer shadow-lg shadow-canina-blue/30"
title={isPlaying ? "توقف" : "پخش"}
>
{isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />}
</button>
{/* -10s button */}
<button
type="button"
onClick={() => skipTime(-10)}
className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۰ ثانیه عقب"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>-10s</span>
</button>
{/* +10s button */}
<button
type="button"
onClick={() => skipTime(10)}
className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۰ ثانیه جلو"
>
<RotateCw className="w-3.5 h-3.5" />
<span>+10s</span>
</button>
{/* Volume Slider: Increases towards the speaker icon */}
<div className="hidden sm:flex items-center gap-2 pr-2" dir="ltr">
<div className="relative w-20 flex items-center h-4">
<div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
<div
className="h-full bg-canina-blue transition-all"
style={{ width: `${volumePercent}%` }}
/>
</div>
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
/>
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow pointer-events-none -translate-x-1/2"
style={{ left: `${volumePercent}%` }}
/>
</div>
<button
type="button"
onClick={toggleMute}
className="p-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer"
title={isMuted ? "صدا وصل" : "بی‌صدا"}
>
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
</div>
</div>
{/* Right Controls (Speed Menu & Fullscreen) */}
<div className="flex items-center gap-2">
{/* Playback Rate Popup Menu with click-outside */}
<div className="relative" ref={rateMenuRef}>
<button
type="button"
onClick={() => setShowRateMenu(!showRateMenu)}
className="px-2.5 py-1 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-mono font-bold transition-all cursor-pointer flex items-center gap-1 border border-white/10"
title="سرعت پخش"
>
<span>{playbackRate}x</span>
</button>
{showRateMenu && (
<div className="absolute bottom-full left-0 mb-2 bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-1.5 shadow-2xl z-30 flex flex-col gap-1 min-w-[75px]">
{PLAYBACK_RATES.map((rate) => (
<button
key={rate}
type="button"
onClick={() => handleRateChange(rate)}
className={`px-3 py-1.5 text-xs font-mono font-bold rounded-xl text-center transition-colors cursor-pointer ${playbackRate === rate
? "bg-canina-blue text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{rate}x
</button>
))}
</div>
)}
</div>
{/* Fullscreen Button */}
<button
type="button"
onClick={toggleFullscreen}
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer"
title={isFullscreen ? "خروج از تمام صفحه" : "تمام صفحه"}
>
{isFullscreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</>
)}
</motion.div>
{/* Video Description Box (Rendered outside & below the video player for clean viewing) */}
{video.description && (
<div className="bg-neutral-900/90 backdrop-blur-md rounded-2xl p-4 text-white text-xs border border-white/10 shadow-xl space-y-2 text-right">
<div className="flex items-center justify-between">
<span className="font-black text-canina-blue text-xs">توضیحات و نکات علمی ویدئو:</span>
{video.description.length > 120 && (
<button
type="button"
onClick={() => setIsExpandedDesc(!isExpandedDesc)}
className="text-white/60 hover:text-white text-[11px] font-bold inline-flex items-center gap-1 cursor-pointer"
>
<span>{isExpandedDesc ? "بستن متن" : "مشاهده کامل متن"}</span>
{isExpandedDesc ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<p className={`font-medium text-white/80 leading-relaxed ${isExpandedDesc ? "" : "line-clamp-2"}`}>
{video.description}
</p>
</div>
)}
</div>
</div>
</AnimatePresence>
);
}