|
|
"use client"; |
|
|
|
|
|
import { useEffect, useRef, useState } 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 VideosPlayer = ({ |
|
|
videosInfo, |
|
|
onVideosReady, |
|
|
}: VideoPlayerProps) => { |
|
|
const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime(); |
|
|
const videoRefs = useRef<HTMLVideoElement[]>([]); |
|
|
|
|
|
const [hiddenVideos, setHiddenVideos] = useState<string[]>([]); |
|
|
|
|
|
const firstVisibleIdx = videosInfo.findIndex( |
|
|
(video) => !hiddenVideos.includes(video.filename), |
|
|
); |
|
|
|
|
|
const visibleCount = videosInfo.filter( |
|
|
(video) => !hiddenVideos.includes(video.filename), |
|
|
).length; |
|
|
const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null); |
|
|
|
|
|
const prevHiddenVideosRef = useRef<string[]>([]); |
|
|
const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({}); |
|
|
const [showHiddenMenu, setShowHiddenMenu] = useState(false); |
|
|
const hiddenMenuRef = useRef<HTMLDivElement | null>(null); |
|
|
const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null); |
|
|
const [videoCodecError, setVideoCodecError] = useState(false); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
videoRefs.current = videoRefs.current.slice(0, videosInfo.length); |
|
|
}, [videosInfo]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
const prevHidden = prevHiddenVideosRef.current; |
|
|
const newlyUnhidden = prevHidden.filter( |
|
|
(filename) => !hiddenVideos.includes(filename), |
|
|
); |
|
|
if (newlyUnhidden.length > 0) { |
|
|
videosInfo.forEach((video, idx) => { |
|
|
if (newlyUnhidden.includes(video.filename)) { |
|
|
const ref = videoRefs.current[idx]; |
|
|
if (ref) { |
|
|
ref.currentTime = currentTime; |
|
|
if (isPlaying) { |
|
|
ref.play().catch(() => {}); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
prevHiddenVideosRef.current = hiddenVideos; |
|
|
}, [hiddenVideos, isPlaying, videosInfo, currentTime]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
const checkCodecSupport = () => { |
|
|
const dummyVideo = document.createElement("video"); |
|
|
const canPlayVideos = dummyVideo.canPlayType( |
|
|
'video/mp4; codecs="av01.0.05M.08"', |
|
|
); |
|
|
setVideoCodecError(!canPlayVideos); |
|
|
}; |
|
|
|
|
|
checkCodecSupport(); |
|
|
}, []); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
videoRefs.current.forEach((video) => { |
|
|
if (video) { |
|
|
if (isPlaying) { |
|
|
video.play().catch(() => console.error("Error playing video")); |
|
|
} else { |
|
|
video.pause(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}, [isPlaying]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!enlargedVideo) return; |
|
|
const handleKeyDown = (e: KeyboardEvent) => { |
|
|
if (e.key === "Escape") { |
|
|
setEnlargedVideo(null); |
|
|
} |
|
|
}; |
|
|
window.addEventListener("keydown", handleKeyDown); |
|
|
|
|
|
const ref = videoContainerRefs.current[enlargedVideo]; |
|
|
if (ref) { |
|
|
ref.scrollIntoView(); |
|
|
} |
|
|
return () => { |
|
|
window.removeEventListener("keydown", handleKeyDown); |
|
|
}; |
|
|
}, [enlargedVideo]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!showHiddenMenu) return; |
|
|
function handleClick(e: MouseEvent) { |
|
|
const menu = hiddenMenuRef.current; |
|
|
const btn = showHiddenBtnRef.current; |
|
|
if ( |
|
|
menu && |
|
|
!menu.contains(e.target as Node) && |
|
|
btn && |
|
|
!btn.contains(e.target as Node) |
|
|
) { |
|
|
setShowHiddenMenu(false); |
|
|
} |
|
|
} |
|
|
document.addEventListener("mousedown", handleClick); |
|
|
return () => document.removeEventListener("mousedown", handleClick); |
|
|
}, [showHiddenMenu]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (hiddenVideos.length === 0 && showHiddenMenu) { |
|
|
setShowHiddenMenu(false); |
|
|
} |
|
|
|
|
|
if (enlargedVideo && hiddenVideos.includes(enlargedVideo)) { |
|
|
setEnlargedVideo(null); |
|
|
} |
|
|
}, [hiddenVideos, showHiddenMenu, enlargedVideo]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
videoRefs.current.forEach((video, index) => { |
|
|
if (video && Math.abs(video.currentTime - currentTime) > 0.2) { |
|
|
const videoInfo = videosInfo[index]; |
|
|
|
|
|
if (videoInfo?.isSegmented) { |
|
|
|
|
|
const segmentStart = videoInfo.segmentStart || 0; |
|
|
const segmentDuration = videoInfo.segmentDuration || 0; |
|
|
|
|
|
if (segmentDuration > 0) { |
|
|
|
|
|
const segmentTime = segmentStart + currentTime; |
|
|
video.currentTime = segmentTime; |
|
|
} |
|
|
} else { |
|
|
|
|
|
video.currentTime = currentTime; |
|
|
} |
|
|
} |
|
|
}); |
|
|
}, [currentTime, videosInfo]); |
|
|
|
|
|
|
|
|
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => { |
|
|
const video = e.target as HTMLVideoElement; |
|
|
if (video && video.duration) { |
|
|
|
|
|
const videoIndex = videoRefs.current.findIndex(ref => ref === video); |
|
|
const videoInfo = videosInfo[videoIndex]; |
|
|
|
|
|
if (videoInfo?.isSegmented) { |
|
|
|
|
|
const segmentStart = videoInfo.segmentStart || 0; |
|
|
const globalTime = Math.max(0, video.currentTime - segmentStart); |
|
|
setCurrentTime(globalTime); |
|
|
} else { |
|
|
|
|
|
setCurrentTime(video.currentTime); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
let videosReadyCount = 0; |
|
|
const onCanPlayThrough = (videoIndex: number) => { |
|
|
const video = videoRefs.current[videoIndex]; |
|
|
const videoInfo = videosInfo[videoIndex]; |
|
|
|
|
|
|
|
|
if (video && videoInfo?.isSegmented) { |
|
|
const segmentStart = videoInfo.segmentStart || 0; |
|
|
const segmentEnd = videoInfo.segmentEnd || video.duration || 0; |
|
|
|
|
|
|
|
|
|
|
|
if (video.currentTime < segmentStart || video.currentTime > segmentEnd) { |
|
|
video.currentTime = segmentStart; |
|
|
} |
|
|
|
|
|
|
|
|
const handleTimeUpdate = () => { |
|
|
if (video.currentTime > segmentEnd) { |
|
|
video.currentTime = segmentStart; |
|
|
if (!video.loop) { |
|
|
video.pause(); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
video.addEventListener('timeupdate', handleTimeUpdate); |
|
|
|
|
|
|
|
|
(video as any)._segmentCleanup = () => { |
|
|
video.removeEventListener('timeupdate', handleTimeUpdate); |
|
|
}; |
|
|
} |
|
|
|
|
|
videosReadyCount += 1; |
|
|
if (videosReadyCount === videosInfo.length) { |
|
|
if (typeof onVideosReady === "function") { |
|
|
onVideosReady(); |
|
|
setIsPlaying(true); |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
videoRefs.current.forEach((video, index) => { |
|
|
if (video) { |
|
|
|
|
|
if (video.readyState >= 4) { |
|
|
onCanPlayThrough(index); |
|
|
} else { |
|
|
const readyHandler = () => onCanPlayThrough(index); |
|
|
video.addEventListener("canplaythrough", readyHandler); |
|
|
(video as any)._readyHandler = readyHandler; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
return () => { |
|
|
videoRefs.current.forEach((video) => { |
|
|
if (video) { |
|
|
|
|
|
if ((video as any)._readyHandler) { |
|
|
video.removeEventListener("canplaythrough", (video as any)._readyHandler); |
|
|
} |
|
|
|
|
|
if ((video as any)._segmentCleanup) { |
|
|
(video as any)._segmentCleanup(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}; |
|
|
}, [videosInfo, onVideosReady, setIsPlaying]); |
|
|
|
|
|
return ( |
|
|
<> |
|
|
{/* Error message */} |
|
|
{videoCodecError && ( |
|
|
<div className="font-medium text-orange-700"> |
|
|
<p> |
|
|
Videos could NOT play because{" "} |
|
|
<a |
|
|
href="https://en.wikipedia.org/wiki/AV1" |
|
|
target="_blank" |
|
|
className="underline" |
|
|
> |
|
|
AV1 |
|
|
</a>{" "} |
|
|
decoding is not available on your browser. |
|
|
</p> |
|
|
<ul className="list-inside list-decimal"> |
|
|
<li> |
|
|
If iPhone:{" "} |
|
|
<span className="italic"> |
|
|
It is supported with A17 chip or higher. |
|
|
</span> |
|
|
</li> |
|
|
<li> |
|
|
If Mac with Safari:{" "} |
|
|
<span className="italic"> |
|
|
It is supported on most browsers except Safari with M1 chip or |
|
|
higher and on Safari with M3 chip or higher. |
|
|
</span> |
|
|
</li> |
|
|
<li> |
|
|
Other:{" "} |
|
|
<span className="italic"> |
|
|
Contact the maintainers on LeRobot discord channel: |
|
|
</span> |
|
|
<a |
|
|
href="https://discord.com/invite/s3KuuzsPFb" |
|
|
target="_blank" |
|
|
className="underline" |
|
|
> |
|
|
https://discord.com/invite/s3KuuzsPFb |
|
|
</a> |
|
|
</li> |
|
|
</ul> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Show Hidden Videos Button */} |
|
|
{hiddenVideos.length > 0 && ( |
|
|
<div className="relative"> |
|
|
<button |
|
|
ref={showHiddenBtnRef} |
|
|
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((prev) => !prev)} |
|
|
> |
|
|
<FaEye /> Show Hidden Videos ({hiddenVideos.length}) |
|
|
</button> |
|
|
{showHiddenMenu && ( |
|
|
<div |
|
|
ref={hiddenMenuRef} |
|
|
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: string[]) => |
|
|
prev.filter((v: string) => v !== filename), |
|
|
) |
|
|
} |
|
|
> |
|
|
{filename} |
|
|
</button> |
|
|
))} |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Videos */} |
|
|
<div className="flex flex-wrap gap-x-2 gap-y-6"> |
|
|
{videosInfo.map((video, idx) => { |
|
|
if (hiddenVideos.includes(video.filename) || videoCodecError) |
|
|
return null; |
|
|
const isEnlarged = enlargedVideo === video.filename; |
|
|
return ( |
|
|
<div |
|
|
key={video.filename} |
|
|
ref={(el) => { |
|
|
videoContainerRefs.current[video.filename] = el; |
|
|
}} |
|
|
className={`${isEnlarged ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center" : "max-w-96"}`} |
|
|
style={isEnlarged ? { height: "100vh", width: "100vw" } : {}} |
|
|
> |
|
|
<p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between"> |
|
|
<span>{video.filename}</span> |
|
|
<span className="flex gap-1"> |
|
|
<button |
|
|
title={isEnlarged ? "Minimize" : "Enlarge"} |
|
|
className="ml-2 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0" |
|
|
onClick={() => |
|
|
setEnlargedVideo(isEnlarged ? null : video.filename) |
|
|
} |
|
|
> |
|
|
{isEnlarged ? <FaCompress /> : <FaExpand />} |
|
|
</button> |
|
|
<button |
|
|
title="Hide Video" |
|
|
className="ml-1 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0" |
|
|
onClick={() => |
|
|
setHiddenVideos((prev: string[]) => [ |
|
|
...prev, |
|
|
video.filename, |
|
|
]) |
|
|
} |
|
|
disabled={visibleCount === 1} |
|
|
> |
|
|
<FaTimes /> |
|
|
</button> |
|
|
</span> |
|
|
</p> |
|
|
<video |
|
|
ref={(el) => { |
|
|
if (el) videoRefs.current[idx] = el; |
|
|
}} |
|
|
muted |
|
|
loop |
|
|
preload="auto" |
|
|
className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`} |
|
|
onTimeUpdate={ |
|
|
idx === firstVisibleIdx ? handleTimeUpdate : undefined |
|
|
} |
|
|
style={isEnlarged ? { zIndex: 41 } : {}} |
|
|
> |
|
|
<source src={video.url} type="video/mp4" /> |
|
|
Your browser does not support the video tag. |
|
|
</video> |
|
|
</div> |
|
|
); |
|
|
})} |
|
|
</div> |
|
|
</> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default VideosPlayer; |
|
|
|