"use client"; import React, { useEffect, useRef } from "react"; import { useTime } from "../context/time-context"; import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa"; import type { VideoInfo } from "@/types"; import { proxyHfUrl } from "@/utils/auth"; const THRESHOLDS = { VIDEO_SYNC_TOLERANCE: 0.2, VIDEO_SEGMENT_BOUNDARY: 0.05, }; const VIDEO_READY_TIMEOUT_MS = 10_000; type VideoPlayerProps = { videosInfo: VideoInfo[]; onVideosReady?: () => void; }; const videoEventCleanup = new WeakMap void>(); export const SimpleVideosPlayer = ({ videosInfo, onVideosReady, }: VideoPlayerProps) => { const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } = useTime(); const videoRefs = useRef<(HTMLVideoElement | null)[]>([]); const [hiddenVideos, setHiddenVideos] = React.useState([]); const [enlargedVideo, setEnlargedVideo] = React.useState(null); const [showHiddenMenu, setShowHiddenMenu] = React.useState(false); const [videosReady, setVideosReady] = React.useState(false); const hiddenSet = React.useMemo(() => new Set(hiddenVideos), [hiddenVideos]); const firstVisibleIdx = videosInfo.findIndex( (video) => !hiddenSet.has(video.filename), ); // Last externalSeekVersion we observed in the sync effect. When the // context's version moves past this, an external seek happened and we // need to drive every video to the new position. const lastSeekVersionRef = useRef(externalSeekVersion); // Mirror firstVisibleIdx into a ref so the videos-ready effect doesn't have // to depend on it. If it did, hiding the first camera would tear the whole // effect down and back up, re-attaching `canplaythrough` listeners that // never re-fire (the videos are already loaded), leaving readyCount stuck // at 0 until the 10s timeout — at which point markReady forces play even // if the user paused. const firstVisibleIdxRef = useRef(firstVisibleIdx); useEffect(() => { firstVisibleIdxRef.current = firstVisibleIdx; }, [firstVisibleIdx]); // Mirror onVideosReady into a ref for the same reason. Parents typically // pass an inline arrow (`onVideosReady={() => setVideosReady(true)}`) which // gets a new identity every render. Including it in the videos-ready // effect's deps caused that effect to tear down + setup on every parent // render. The setup branch then ran `queueMicrotask(handleLoadedData)` for // every already-loaded video (true after the first load), seeking each // back to segmentStart — videos got pinned on their first frame and never // advanced. const onVideosReadyRef = useRef(onVideosReady); useEffect(() => { onVideosReadyRef.current = onVideosReady; }, [onVideosReady]); // Initialize video refs array useEffect(() => { videoRefs.current = videoRefs.current.slice(0, videosInfo.length); }, [videosInfo.length]); // Handle videos ready — with a timeout fallback so the UI never hangs // if a video fails to reach canplaythrough (e.g. network stall). useEffect(() => { let readyCount = 0; let resolved = false; const markReady = () => { if (resolved) return; resolved = true; setVideosReady(true); onVideosReadyRef.current?.(); setIsPlaying(true); }; const checkReady = () => { readyCount++; if (readyCount >= videosInfo.length) markReady(); }; const timeout = setTimeout(markReady, VIDEO_READY_TIMEOUT_MS); // Coordinated loop reset — when the primary video hits its segment end // (or natural end), seek every camera to its own segmentStart in a // single synchronous burst. The previous design seeked the primary, // then bumped externalSeekVersion which scheduled the other seeks via // a React render — leaving an 80ms (throttled) gap where the primary // played fresh frames while the others still showed the segment-end // frame. Now the gap is microseconds. const loopAllVideos = () => { videoRefs.current.forEach((other, otherIdx) => { if (!other) return; const otherInfo = videosInfo[otherIdx]; if (!otherInfo) return; other.currentTime = otherInfo.segmentStart ?? 0; }); // Update the slider as a status report — don't bump externalSeekVersion // since we already drove every video to its target. seek(0, "video"); }; videoRefs.current.forEach((video, index) => { if (!video) return; const info = videosInfo[index]; // One timeupdate handler per video covers both jobs: // (a) loop-reset on segmented videos at segment-end // (b) reporting the primary video's currentTime back to the context const handleTimeUpdate = () => { if (info.isSegmented) { const segmentEnd = info.segmentEnd ?? video.duration; const segmentStart = info.segmentStart ?? 0; if ( video.currentTime >= segmentEnd - THRESHOLDS.VIDEO_SEGMENT_BOUNDARY ) { // Primary drives the coordinated loop. Non-primary cameras // that race ahead just snap to segmentStart and wait — the // primary's next loop will re-align everyone. if (index === firstVisibleIdxRef.current) { loopAllVideos(); } else { video.currentTime = segmentStart; } return; } } if (index === firstVisibleIdxRef.current) { let globalTime = video.currentTime; if (info.isSegmented) { globalTime = video.currentTime - (info.segmentStart ?? 0); } seek(globalTime, "video"); } }; // For segmented videos, snap into the segment when play() is called // — covers the case where the user paused outside the segment range. const handlePlay = info.isSegmented ? () => { const segmentStart = info.segmentStart ?? 0; const segmentEnd = info.segmentEnd ?? video.duration; if ( video.currentTime < segmentStart || video.currentTime >= segmentEnd ) { video.currentTime = segmentStart; } } : null; const handleLoadedData = info.isSegmented ? () => { video.currentTime = info.segmentStart ?? 0; checkReady(); } : null; const handleEnded = info.isSegmented ? null : () => { // Same coordinated loop strategy for non-segmented videos at // their natural end — primary drives, others wait for primary // to align them. if (index === firstVisibleIdxRef.current) { loopAllVideos(); } else { video.currentTime = 0; } }; video.addEventListener("timeupdate", handleTimeUpdate); if (handlePlay) video.addEventListener("play", handlePlay); if (handleEnded) video.addEventListener("ended", handleEnded); // Already-loaded videos (cached or fast network) will never re-fire // canplaythrough / loadeddata after we attach the listener — so check // readyState synchronously and mark ready in a microtask. Without this, // checkReady wouldn't be called and only the 10s fallback timeout would // eventually unfreeze the UI. if (info.isSegmented && handleLoadedData) { if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { queueMicrotask(handleLoadedData); } else { video.addEventListener("loadeddata", handleLoadedData); } } else if (!info.isSegmented) { if (video.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { queueMicrotask(checkReady); } else { video.addEventListener("canplaythrough", checkReady, { once: true }); } } videoEventCleanup.set(video, () => { video.removeEventListener("timeupdate", handleTimeUpdate); if (handlePlay) video.removeEventListener("play", handlePlay); if (handleLoadedData) video.removeEventListener("loadeddata", handleLoadedData); if (handleEnded) video.removeEventListener("ended", handleEnded); video.removeEventListener("canplaythrough", checkReady); }); }); return () => { clearTimeout(timeout); videoRefs.current.forEach((video) => { if (!video) return; const cleanup = videoEventCleanup.get(video); if (cleanup) { cleanup(); videoEventCleanup.delete(video); } }); }; // firstVisibleIdx intentionally omitted — we read it via ref so hiding // the first camera doesn't reset readiness (see the comment near // firstVisibleIdxRef above). // onVideosReady intentionally omitted — read via onVideosReadyRef so // an inline parent prop doesn't tear this effect down on every render. }, [videosInfo, setIsPlaying, seek]); // Handle play/pause — skip hidden videos useEffect(() => { if (!videosReady) return; videoRefs.current.forEach((video, idx) => { if (!video || hiddenSet.has(videosInfo[idx].filename)) return; if (isPlaying) { video.play().catch((e) => { if (e.name !== "AbortError") { console.error("Error playing video"); } }); } else { video.pause(); } }); }, [isPlaying, videosReady, hiddenSet, videosInfo]); // Drive every video to currentTime on external seeks (slider drag, chart // click, loop reset). The version-based check replaces a 0.3s heuristic // that misfired when a network stall produced a >0.3s timeupdate jump // and incorrectly classified it as a user seek — causing every camera to // re-seek, which itself stalled them in a feedback spiral. useEffect(() => { if (!videosReady) return; if (externalSeekVersion === lastSeekVersionRef.current) return; lastSeekVersionRef.current = externalSeekVersion; videoRefs.current.forEach((video, index) => { if (!video) return; if (hiddenSet.has(videosInfo[index].filename)) return; const info = videosInfo[index]; let targetTime = currentTime; if (info.isSegmented) { targetTime = (info.segmentStart ?? 0) + currentTime; } if ( Math.abs(video.currentTime - targetTime) > THRESHOLDS.VIDEO_SYNC_TOLERANCE ) { video.currentTime = targetTime; } }); }, [externalSeekVersion, currentTime, videosInfo, videosReady, hiddenSet]); return ( <> {/* Hidden videos menu */} {hiddenVideos.length > 0 && (
{showHiddenMenu && (
Restore hidden videos
{hiddenVideos.map((filename) => ( ))}
)}
)} {/* Videos */}
{videosInfo.map((info, idx) => { if (hiddenVideos.includes(info.filename)) return null; const isEnlarged = enlargedVideo === info.filename; return (

{info.filename}

); })}
); }; export default SimpleVideosPlayer;