| "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; |
|
|