canina/frontend/application/components/VideoModalPlayer.tsx
parsa aghaei acc6f9e57b
Some checks failed
Deploy Canina / deploy (push) Has been cancelled
feat(player): add modern custom video player with auto-hide controls, speed, jumps and download
2026-08-17 16:05:45 +03:30

504 lines
19 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";
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 controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = 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);
// Reset states on open/close
useEffect(() => {
if (isOpen) {
setIsPlaying(true);
setShowControls(true);
setIsExpandedDesc(false);
setShowRateMenu(false);
} else {
setIsPlaying(false);
setCurrentTime(0);
}
}, [isOpen, video]);
// 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");
return (
<AnimatePresence>
<div className="fixed inset-0 z-[120] flex items-center justify-center p-3 sm:p-6 font-vazir" dir="rtl">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/90 backdrop-blur-xl"
/>
{/* Modal Window */}
<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 max-w-5xl aspect-video 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
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: video.videoUrl || '' }}
/>
</div>
) : (
<>
{/* Native Video Element */}
<div
className="absolute inset-0 flex items-center justify-center cursor-pointer"
onClick={togglePlay}
>
<video
ref={videoRef}
src={video.videoUrl}
poster={video.thumbnail}
autoPlay
playsInline
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={() => {
if (videoRef.current) setCurrentTime(videoRef.current.currentTime);
}}
onLoadedMetadata={() => {
if (videoRef.current) setDuration(videoRef.current.duration);
setIsBuffering(false);
}}
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-base sm: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()}
>
{/* Expandable Description Box if available */}
{video.description && (
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-3 text-white text-xs border border-white/10">
<p className={`font-medium leading-relaxed ${isExpandedDesc ? "" : "line-clamp-1"}`}>
{video.description}
</p>
{video.description.length > 80 && (
<button
type="button"
onClick={() => setIsExpandedDesc(!isExpandedDesc)}
className="text-canina-blue hover:underline font-bold mt-1 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>
)}
{/* Progress / Seek Bar */}
<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">
<input
type="range"
min={0}
max={duration || 100}
step={0.1}
value={currentTime}
onChange={handleSeek}
className="w-full h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hover:h-2 transition-all"
/>
</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">
{/* Left Controls (Playback & Jumps) */}
<div className="flex items-center gap-2 sm:gap-4">
{/* 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
type="button"
onClick={() => skipTime(-10)}
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold"
title="۱۰ ثانیه عقب"
>
<RotateCcw className="w-4 h-4" />
<span className="text-[10px]">10s</span>
</button>
{/* +10s */}
<button
type="button"
onClick={() => skipTime(10)}
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold"
title="۱۰ ثانیه جلو"
>
<RotateCw className="w-4 h-4" />
<span className="text-[10px]">10s</span>
</button>
{/* Volume slider */}
<div className="hidden sm:flex items-center gap-2 group/vol">
<button
type="button"
onClick={toggleMute}
className="p-2 text-white/70 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>
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue"
/>
</div>
</div>
{/* Right Controls (Speed Menu & Fullscreen) */}
<div className="flex items-center gap-2">
{/* Playback Rate Popup Menu */}
<div className="relative">
<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"
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>
</div>
</AnimatePresence>
);
}