Raffael-Kultyshev's picture
Initial viewer for humanoid robots dataset
6f0655f
raw
history blame
14.1 kB
"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[]>([]);
// Hidden/enlarged state and hidden menu
const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
// 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<string | null>(null);
// Track previous hiddenVideos for comparison
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);
// 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<HTMLVideoElement>) => {
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 && (
<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;