"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([]); // Hidden/enlarged state and hidden menu const [hiddenVideos, setHiddenVideos] = useState([]); // Find the index of the first visible (not hidden) video const firstVisibleIdx = videosInfo.findIndex( (video) => !hiddenVideos.includes(video.filename), ); // Count of visible videos const visibleCount = videosInfo.filter( (video) => !hiddenVideos.includes(video.filename), ).length; const [enlargedVideo, setEnlargedVideo] = useState(null); // Track previous hiddenVideos for comparison const prevHiddenVideosRef = useRef([]); const videoContainerRefs = useRef>({}); const [showHiddenMenu, setShowHiddenMenu] = useState(false); const hiddenMenuRef = useRef(null); const showHiddenBtnRef = useRef(null); const [videoCodecError, setVideoCodecError] = useState(false); // Initialize video refs useEffect(() => { videoRefs.current = videoRefs.current.slice(0, videosInfo.length); }, [videosInfo]); // When videos get unhidden, start playing them if it was playing useEffect(() => { // Find which videos were just unhidden 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]); // Check video codec support useEffect(() => { const checkCodecSupport = () => { const dummyVideo = document.createElement("video"); const canPlayVideos = dummyVideo.canPlayType( 'video/mp4; codecs="av01.0.05M.08"', ); setVideoCodecError(!canPlayVideos); }; checkCodecSupport(); }, []); // Handle play/pause useEffect(() => { videoRefs.current.forEach((video) => { if (video) { if (isPlaying) { video.play().catch(() => console.error("Error playing video")); } else { video.pause(); } } }); }, [isPlaying]); // Minimize enlarged video on Escape key useEffect(() => { if (!enlargedVideo) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { setEnlargedVideo(null); } }; window.addEventListener("keydown", handleKeyDown); // Scroll enlarged video into view const ref = videoContainerRefs.current[enlargedVideo]; if (ref) { ref.scrollIntoView(); } return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [enlargedVideo]); // Close hidden videos dropdown on outside click 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]); // Close dropdown if no hidden videos useEffect(() => { if (hiddenVideos.length === 0 && showHiddenMenu) { setShowHiddenMenu(false); } // Minimize if enlarged video is hidden if (enlargedVideo && hiddenVideos.includes(enlargedVideo)) { setEnlargedVideo(null); } }, [hiddenVideos, showHiddenMenu, enlargedVideo]); // Sync video times (with segment awareness) useEffect(() => { videoRefs.current.forEach((video, index) => { if (video && Math.abs(video.currentTime - currentTime) > 0.2) { const videoInfo = videosInfo[index]; if (videoInfo?.isSegmented) { // For segmented videos, map the global time to segment time const segmentStart = videoInfo.segmentStart || 0; const segmentDuration = videoInfo.segmentDuration || 0; if (segmentDuration > 0) { // Map currentTime (0 to segmentDuration) to video time (segmentStart to segmentEnd) const segmentTime = segmentStart + currentTime; video.currentTime = segmentTime; } } else { // For non-segmented videos, use direct time mapping video.currentTime = currentTime; } } }); }, [currentTime, videosInfo]); // Handle time update const handleTimeUpdate = (e: React.SyntheticEvent) => { const video = e.target as HTMLVideoElement; if (video && video.duration) { // Find the video info for this video element const videoIndex = videoRefs.current.findIndex(ref => ref === video); const videoInfo = videosInfo[videoIndex]; if (videoInfo?.isSegmented) { // For segmented videos, map the video time back to global time (0 to segmentDuration) const segmentStart = videoInfo.segmentStart || 0; const globalTime = Math.max(0, video.currentTime - segmentStart); setCurrentTime(globalTime); } else { // For non-segmented videos, use direct time mapping setCurrentTime(video.currentTime); } } }; // Handle video ready and setup segmentation useEffect(() => { let videosReadyCount = 0; const onCanPlayThrough = (videoIndex: number) => { const video = videoRefs.current[videoIndex]; const videoInfo = videosInfo[videoIndex]; // Setup video segmentation for v3.0 chunked videos if (video && videoInfo?.isSegmented) { const segmentStart = videoInfo.segmentStart || 0; const segmentEnd = videoInfo.segmentEnd || video.duration || 0; // Set initial time to segment start if not already set if (video.currentTime < segmentStart || video.currentTime > segmentEnd) { video.currentTime = segmentStart; } // Add event listener to handle segment boundaries const handleTimeUpdate = () => { if (video.currentTime > segmentEnd) { video.currentTime = segmentStart; if (!video.loop) { video.pause(); } } }; video.addEventListener('timeupdate', handleTimeUpdate); // Store cleanup function (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 already ready, call the handler immediately 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) { // Remove ready handler if ((video as any)._readyHandler) { video.removeEventListener("canplaythrough", (video as any)._readyHandler); } // Remove segment handler if ((video as any)._segmentCleanup) { (video as any)._segmentCleanup(); } } }); }; }, [videosInfo, onVideosReady, setIsPlaying]); return ( <> {/* Error message */} {videoCodecError && (

Videos could NOT play because{" "} AV1 {" "} decoding is not available on your browser.

  • If iPhone:{" "} It is supported with A17 chip or higher.
  • If Mac with Safari:{" "} It is supported on most browsers except Safari with M1 chip or higher and on Safari with M3 chip or higher.
  • Other:{" "} Contact the maintainers on LeRobot discord channel: https://discord.com/invite/s3KuuzsPFb
)} {/* Show Hidden Videos Button */} {hiddenVideos.length > 0 && (
{showHiddenMenu && (
Restore hidden videos:
{hiddenVideos.map((filename) => ( ))}
)}
)} {/* Videos */}
{videosInfo.map((video, idx) => { if (hiddenVideos.includes(video.filename) || videoCodecError) return null; const isEnlarged = enlargedVideo === video.filename; return (
{ 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" } : {}} >

{video.filename}

); })}
); }; export default VideosPlayer;