|
|
"use client"; |
|
|
|
|
|
import React, { useEffect, useRef } from "react"; |
|
|
import { useTime } from "../context/time-context"; |
|
|
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa"; |
|
|
|
|
|
type VideoInfo = { |
|
|
filename: string; |
|
|
url: string; |
|
|
isSegmented?: boolean; |
|
|
segmentStart?: number; |
|
|
segmentEnd?: number; |
|
|
segmentDuration?: number; |
|
|
}; |
|
|
|
|
|
type VideoPlayerProps = { |
|
|
videosInfo: VideoInfo[]; |
|
|
onVideosReady?: () => void; |
|
|
}; |
|
|
|
|
|
export const SimpleVideosPlayer = ({ |
|
|
videosInfo, |
|
|
onVideosReady, |
|
|
}: VideoPlayerProps) => { |
|
|
const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime(); |
|
|
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]); |
|
|
const [hiddenVideos, setHiddenVideos] = React.useState<string[]>([]); |
|
|
const [enlargedVideo, setEnlargedVideo] = React.useState<string | null>(null); |
|
|
const [showHiddenMenu, setShowHiddenMenu] = React.useState(false); |
|
|
const [videosReady, setVideosReady] = React.useState(false); |
|
|
|
|
|
const firstVisibleIdx = videosInfo.findIndex( |
|
|
(video) => !hiddenVideos.includes(video.filename) |
|
|
); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
videoRefs.current = videoRefs.current.slice(0, videosInfo.length); |
|
|
}, [videosInfo.length]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
let readyCount = 0; |
|
|
|
|
|
const checkReady = () => { |
|
|
readyCount++; |
|
|
if (readyCount === videosInfo.length && onVideosReady) { |
|
|
setVideosReady(true); |
|
|
onVideosReady(); |
|
|
setIsPlaying(true); |
|
|
} |
|
|
}; |
|
|
|
|
|
videoRefs.current.forEach((video, index) => { |
|
|
if (video) { |
|
|
const info = videosInfo[index]; |
|
|
|
|
|
|
|
|
if (info.isSegmented) { |
|
|
const handleTimeUpdate = () => { |
|
|
const segmentEnd = info.segmentEnd || video.duration; |
|
|
const segmentStart = info.segmentStart || 0; |
|
|
|
|
|
if (video.currentTime >= segmentEnd - 0.05) { |
|
|
video.currentTime = segmentStart; |
|
|
|
|
|
if (index === firstVisibleIdx) { |
|
|
setCurrentTime(0); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleLoadedData = () => { |
|
|
video.currentTime = info.segmentStart || 0; |
|
|
checkReady(); |
|
|
}; |
|
|
|
|
|
video.addEventListener('timeupdate', handleTimeUpdate); |
|
|
video.addEventListener('loadeddata', handleLoadedData); |
|
|
|
|
|
|
|
|
(video as any)._segmentHandlers = () => { |
|
|
video.removeEventListener('timeupdate', handleTimeUpdate); |
|
|
video.removeEventListener('loadeddata', handleLoadedData); |
|
|
}; |
|
|
} else { |
|
|
|
|
|
const handleEnded = () => { |
|
|
video.currentTime = 0; |
|
|
if (index === firstVisibleIdx) { |
|
|
setCurrentTime(0); |
|
|
} |
|
|
}; |
|
|
|
|
|
video.addEventListener('ended', handleEnded); |
|
|
video.addEventListener('canplaythrough', checkReady, { once: true }); |
|
|
|
|
|
|
|
|
(video as any)._segmentHandlers = () => { |
|
|
video.removeEventListener('ended', handleEnded); |
|
|
}; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
return () => { |
|
|
videoRefs.current.forEach((video) => { |
|
|
if (video && (video as any)._segmentHandlers) { |
|
|
(video as any)._segmentHandlers(); |
|
|
} |
|
|
}); |
|
|
}; |
|
|
}, [videosInfo, onVideosReady, setIsPlaying, firstVisibleIdx, setCurrentTime]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!videosReady) return; |
|
|
|
|
|
videoRefs.current.forEach((video, idx) => { |
|
|
if (video && !hiddenVideos.includes(videosInfo[idx].filename)) { |
|
|
if (isPlaying) { |
|
|
video.play().catch(e => { |
|
|
if (e.name !== 'AbortError') { |
|
|
console.error("Error playing video"); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
video.pause(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}, [isPlaying, videosReady, hiddenVideos, videosInfo]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!videosReady) return; |
|
|
|
|
|
videoRefs.current.forEach((video, index) => { |
|
|
if (video && !hiddenVideos.includes(videosInfo[index].filename)) { |
|
|
const info = videosInfo[index]; |
|
|
let targetTime = currentTime; |
|
|
|
|
|
if (info.isSegmented) { |
|
|
targetTime = (info.segmentStart || 0) + currentTime; |
|
|
} |
|
|
|
|
|
if (Math.abs(video.currentTime - targetTime) > 0.2) { |
|
|
video.currentTime = targetTime; |
|
|
} |
|
|
} |
|
|
}); |
|
|
}, [currentTime, videosInfo, videosReady, hiddenVideos]); |
|
|
|
|
|
|
|
|
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => { |
|
|
const video = e.target as HTMLVideoElement; |
|
|
const videoIndex = videoRefs.current.findIndex(ref => ref === video); |
|
|
const info = videosInfo[videoIndex]; |
|
|
|
|
|
if (info) { |
|
|
let globalTime = video.currentTime; |
|
|
if (info.isSegmented) { |
|
|
globalTime = video.currentTime - (info.segmentStart || 0); |
|
|
} |
|
|
setCurrentTime(globalTime); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
const handlePlay = (video: HTMLVideoElement, info: VideoInfo) => { |
|
|
if (info.isSegmented) { |
|
|
const segmentStart = info.segmentStart || 0; |
|
|
const segmentEnd = info.segmentEnd || video.duration; |
|
|
|
|
|
if (video.currentTime < segmentStart || video.currentTime >= segmentEnd) { |
|
|
video.currentTime = segmentStart; |
|
|
} |
|
|
} |
|
|
video.play(); |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<> |
|
|
{/* Hidden videos menu */} |
|
|
{hiddenVideos.length > 0 && ( |
|
|
<div className="relative mb-4"> |
|
|
<button |
|
|
className="flex items-center gap-2 rounded bg-slate-800 px-3 py-2 text-sm text-slate-100 hover:bg-slate-700 border border-slate-500" |
|
|
onClick={() => setShowHiddenMenu(!showHiddenMenu)} |
|
|
> |
|
|
<FaEye /> Show Hidden Videos ({hiddenVideos.length}) |
|
|
</button> |
|
|
{showHiddenMenu && ( |
|
|
<div className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50"> |
|
|
<div className="mb-2 text-xs text-slate-300"> |
|
|
Restore hidden videos: |
|
|
</div> |
|
|
{hiddenVideos.map((filename) => ( |
|
|
<button |
|
|
key={filename} |
|
|
className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100" |
|
|
onClick={() => setHiddenVideos(prev => prev.filter(v => v !== filename))} |
|
|
> |
|
|
{filename} |
|
|
</button> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Videos */} |
|
|
<div className="flex flex-wrap gap-x-2 gap-y-6"> |
|
|
{videosInfo.map((info, idx) => { |
|
|
if (hiddenVideos.includes(info.filename)) return null; |
|
|
|
|
|
const isEnlarged = enlargedVideo === info.filename; |
|
|
const isFirstVisible = idx === firstVisibleIdx; |
|
|
|
|
|
return ( |
|
|
<div |
|
|
key={info.filename} |
|
|
className={`${ |
|
|
isEnlarged |
|
|
? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center" |
|
|
: "max-w-96" |
|
|
}`} |
|
|
> |
|
|
<p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between"> |
|
|
<span>{info.filename}</span> |
|
|
<span className="flex gap-1"> |
|
|
<button |
|
|
title={isEnlarged ? "Minimize" : "Enlarge"} |
|
|
className="ml-2 p-1 hover:bg-slate-700 rounded" |
|
|
onClick={() => setEnlargedVideo(isEnlarged ? null : info.filename)} |
|
|
> |
|
|
{isEnlarged ? <FaCompress /> : <FaExpand />} |
|
|
</button> |
|
|
<button |
|
|
title="Hide Video" |
|
|
className="ml-1 p-1 hover:bg-slate-700 rounded" |
|
|
onClick={() => setHiddenVideos(prev => [...prev, info.filename])} |
|
|
disabled={videosInfo.filter(v => !hiddenVideos.includes(v.filename)).length === 1} |
|
|
> |
|
|
<FaTimes /> |
|
|
</button> |
|
|
</span> |
|
|
</p> |
|
|
<video |
|
|
ref={el => videoRefs.current[idx] = el} |
|
|
className={`w-full object-contain ${ |
|
|
isEnlarged ? "max-h-[90vh] max-w-[90vw]" : "" |
|
|
}`} |
|
|
muted |
|
|
preload="auto" |
|
|
onPlay={(e) => handlePlay(e.currentTarget, info)} |
|
|
onTimeUpdate={isFirstVisible ? handleTimeUpdate : undefined} |
|
|
> |
|
|
<source src={info.url} type="video/mp4" /> |
|
|
Your browser does not support the video tag. |
|
|
</video> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default SimpleVideosPlayer; |
|
|
|