Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- src/components/simple-videos-player.tsx +59 -43
- src/components/videos-player.tsx +51 -46
- src/context/time-context.tsx +39 -5
src/components/simple-videos-player.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, { useEffect, useRef } from "react";
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
|
@@ -10,6 +10,8 @@ const THRESHOLDS = {
|
|
| 10 |
VIDEO_SEGMENT_BOUNDARY: 0.05,
|
| 11 |
};
|
| 12 |
|
|
|
|
|
|
|
| 13 |
type VideoPlayerProps = {
|
| 14 |
videosInfo: VideoInfo[];
|
| 15 |
onVideosReady?: () => void;
|
|
@@ -28,8 +30,10 @@ export const SimpleVideosPlayer = ({
|
|
| 28 |
const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
|
| 29 |
const [videosReady, setVideosReady] = React.useState(false);
|
| 30 |
|
|
|
|
|
|
|
| 31 |
const firstVisibleIdx = videosInfo.findIndex(
|
| 32 |
-
(video) => !
|
| 33 |
);
|
| 34 |
|
| 35 |
// Tracks the last time value set by the primary video's onTimeUpdate.
|
|
@@ -41,24 +45,31 @@ export const SimpleVideosPlayer = ({
|
|
| 41 |
videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
|
| 42 |
}, [videosInfo.length]);
|
| 43 |
|
| 44 |
-
// Handle videos ready
|
|
|
|
| 45 |
useEffect(() => {
|
| 46 |
let readyCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
const checkReady = () => {
|
| 49 |
readyCount++;
|
| 50 |
-
if (readyCount =
|
| 51 |
-
setVideosReady(true);
|
| 52 |
-
onVideosReady();
|
| 53 |
-
setIsPlaying(true);
|
| 54 |
-
}
|
| 55 |
};
|
| 56 |
|
|
|
|
|
|
|
| 57 |
videoRefs.current.forEach((video, index) => {
|
| 58 |
if (video) {
|
| 59 |
const info = videosInfo[index];
|
| 60 |
|
| 61 |
-
// Setup segment boundaries
|
| 62 |
if (info.isSegmented) {
|
| 63 |
const handleTimeUpdate = () => {
|
| 64 |
const segmentEnd = info.segmentEnd || video.duration;
|
|
@@ -69,7 +80,6 @@ export const SimpleVideosPlayer = ({
|
|
| 69 |
segmentEnd - THRESHOLDS.VIDEO_SEGMENT_BOUNDARY
|
| 70 |
) {
|
| 71 |
video.currentTime = segmentStart;
|
| 72 |
-
// Also update the global time to reset to start
|
| 73 |
if (index === firstVisibleIdx) {
|
| 74 |
setCurrentTime(0);
|
| 75 |
}
|
|
@@ -89,7 +99,6 @@ export const SimpleVideosPlayer = ({
|
|
| 89 |
video.removeEventListener("loadeddata", handleLoadedData);
|
| 90 |
});
|
| 91 |
} else {
|
| 92 |
-
// For non-segmented videos, handle end of video
|
| 93 |
const handleEnded = () => {
|
| 94 |
video.currentTime = 0;
|
| 95 |
if (index === firstVisibleIdx) {
|
|
@@ -108,6 +117,7 @@ export const SimpleVideosPlayer = ({
|
|
| 108 |
});
|
| 109 |
|
| 110 |
return () => {
|
|
|
|
| 111 |
videoRefs.current.forEach((video) => {
|
| 112 |
if (!video) return;
|
| 113 |
const cleanup = videoEventCleanup.get(video);
|
|
@@ -125,24 +135,23 @@ export const SimpleVideosPlayer = ({
|
|
| 125 |
setCurrentTime,
|
| 126 |
]);
|
| 127 |
|
| 128 |
-
// Handle play/pause
|
| 129 |
useEffect(() => {
|
| 130 |
if (!videosReady) return;
|
| 131 |
|
| 132 |
videoRefs.current.forEach((video, idx) => {
|
| 133 |
-
if (video
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
}
|
| 143 |
}
|
| 144 |
});
|
| 145 |
-
}, [isPlaying, videosReady,
|
| 146 |
|
| 147 |
// Sync all video times when currentTime changes.
|
| 148 |
// For the primary video, only seek when the change came from an external source
|
|
@@ -155,9 +164,7 @@ export const SimpleVideosPlayer = ({
|
|
| 155 |
|
| 156 |
videoRefs.current.forEach((video, index) => {
|
| 157 |
if (!video) return;
|
| 158 |
-
if (
|
| 159 |
-
|
| 160 |
-
// Skip the primary video unless the time was changed externally
|
| 161 |
if (index === firstVisibleIdx && !isExternalSeek) return;
|
| 162 |
|
| 163 |
const info = videosInfo[index];
|
|
@@ -166,27 +173,33 @@ export const SimpleVideosPlayer = ({
|
|
| 166 |
targetTime = (info.segmentStart || 0) + currentTime;
|
| 167 |
}
|
| 168 |
|
| 169 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 170 |
video.currentTime = targetTime;
|
| 171 |
}
|
| 172 |
});
|
| 173 |
-
}, [currentTime, videosInfo, videosReady,
|
| 174 |
|
| 175 |
-
//
|
| 176 |
-
const
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
}
|
| 189 |
-
|
|
|
|
| 190 |
|
| 191 |
// Handle play click for segmented videos
|
| 192 |
const handlePlay = (video: HTMLVideoElement, info: VideoInfo) => {
|
|
@@ -289,8 +302,11 @@ export const SimpleVideosPlayer = ({
|
|
| 289 |
}`}
|
| 290 |
muted
|
| 291 |
preload="auto"
|
|
|
|
| 292 |
onPlay={(e) => handlePlay(e.currentTarget, info)}
|
| 293 |
-
onTimeUpdate={
|
|
|
|
|
|
|
| 294 |
>
|
| 295 |
<source src={info.url} type="video/mp4" />
|
| 296 |
Your browser does not support the video tag.
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, { useEffect, useRef, useCallback } from "react";
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
|
|
|
| 10 |
VIDEO_SEGMENT_BOUNDARY: 0.05,
|
| 11 |
};
|
| 12 |
|
| 13 |
+
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
| 14 |
+
|
| 15 |
type VideoPlayerProps = {
|
| 16 |
videosInfo: VideoInfo[];
|
| 17 |
onVideosReady?: () => void;
|
|
|
|
| 30 |
const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
|
| 31 |
const [videosReady, setVideosReady] = React.useState(false);
|
| 32 |
|
| 33 |
+
const hiddenSet = React.useMemo(() => new Set(hiddenVideos), [hiddenVideos]);
|
| 34 |
+
|
| 35 |
const firstVisibleIdx = videosInfo.findIndex(
|
| 36 |
+
(video) => !hiddenSet.has(video.filename),
|
| 37 |
);
|
| 38 |
|
| 39 |
// Tracks the last time value set by the primary video's onTimeUpdate.
|
|
|
|
| 45 |
videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
|
| 46 |
}, [videosInfo.length]);
|
| 47 |
|
| 48 |
+
// Handle videos ready — with a timeout fallback so the UI never hangs
|
| 49 |
+
// if a video fails to reach canplaythrough (e.g. network stall).
|
| 50 |
useEffect(() => {
|
| 51 |
let readyCount = 0;
|
| 52 |
+
let resolved = false;
|
| 53 |
+
|
| 54 |
+
const markReady = () => {
|
| 55 |
+
if (resolved) return;
|
| 56 |
+
resolved = true;
|
| 57 |
+
setVideosReady(true);
|
| 58 |
+
onVideosReady?.();
|
| 59 |
+
setIsPlaying(true);
|
| 60 |
+
};
|
| 61 |
|
| 62 |
const checkReady = () => {
|
| 63 |
readyCount++;
|
| 64 |
+
if (readyCount >= videosInfo.length) markReady();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
};
|
| 66 |
|
| 67 |
+
const timeout = setTimeout(markReady, VIDEO_READY_TIMEOUT_MS);
|
| 68 |
+
|
| 69 |
videoRefs.current.forEach((video, index) => {
|
| 70 |
if (video) {
|
| 71 |
const info = videosInfo[index];
|
| 72 |
|
|
|
|
| 73 |
if (info.isSegmented) {
|
| 74 |
const handleTimeUpdate = () => {
|
| 75 |
const segmentEnd = info.segmentEnd || video.duration;
|
|
|
|
| 80 |
segmentEnd - THRESHOLDS.VIDEO_SEGMENT_BOUNDARY
|
| 81 |
) {
|
| 82 |
video.currentTime = segmentStart;
|
|
|
|
| 83 |
if (index === firstVisibleIdx) {
|
| 84 |
setCurrentTime(0);
|
| 85 |
}
|
|
|
|
| 99 |
video.removeEventListener("loadeddata", handleLoadedData);
|
| 100 |
});
|
| 101 |
} else {
|
|
|
|
| 102 |
const handleEnded = () => {
|
| 103 |
video.currentTime = 0;
|
| 104 |
if (index === firstVisibleIdx) {
|
|
|
|
| 117 |
});
|
| 118 |
|
| 119 |
return () => {
|
| 120 |
+
clearTimeout(timeout);
|
| 121 |
videoRefs.current.forEach((video) => {
|
| 122 |
if (!video) return;
|
| 123 |
const cleanup = videoEventCleanup.get(video);
|
|
|
|
| 135 |
setCurrentTime,
|
| 136 |
]);
|
| 137 |
|
| 138 |
+
// Handle play/pause — skip hidden videos
|
| 139 |
useEffect(() => {
|
| 140 |
if (!videosReady) return;
|
| 141 |
|
| 142 |
videoRefs.current.forEach((video, idx) => {
|
| 143 |
+
if (!video || hiddenSet.has(videosInfo[idx].filename)) return;
|
| 144 |
+
if (isPlaying) {
|
| 145 |
+
video.play().catch((e) => {
|
| 146 |
+
if (e.name !== "AbortError") {
|
| 147 |
+
console.error("Error playing video");
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
} else {
|
| 151 |
+
video.pause();
|
|
|
|
| 152 |
}
|
| 153 |
});
|
| 154 |
+
}, [isPlaying, videosReady, hiddenSet, videosInfo]);
|
| 155 |
|
| 156 |
// Sync all video times when currentTime changes.
|
| 157 |
// For the primary video, only seek when the change came from an external source
|
|
|
|
| 164 |
|
| 165 |
videoRefs.current.forEach((video, index) => {
|
| 166 |
if (!video) return;
|
| 167 |
+
if (hiddenSet.has(videosInfo[index].filename)) return;
|
|
|
|
|
|
|
| 168 |
if (index === firstVisibleIdx && !isExternalSeek) return;
|
| 169 |
|
| 170 |
const info = videosInfo[index];
|
|
|
|
| 173 |
targetTime = (info.segmentStart || 0) + currentTime;
|
| 174 |
}
|
| 175 |
|
| 176 |
+
if (
|
| 177 |
+
Math.abs(video.currentTime - targetTime) >
|
| 178 |
+
THRESHOLDS.VIDEO_SYNC_TOLERANCE
|
| 179 |
+
) {
|
| 180 |
video.currentTime = targetTime;
|
| 181 |
}
|
| 182 |
});
|
| 183 |
+
}, [currentTime, videosInfo, videosReady, hiddenSet, firstVisibleIdx]);
|
| 184 |
|
| 185 |
+
// Stable per-index timeupdate handlers avoid findIndex scan on every event
|
| 186 |
+
const makeTimeUpdateHandler = useCallback(
|
| 187 |
+
(index: number) => {
|
| 188 |
+
return () => {
|
| 189 |
+
const video = videoRefs.current[index];
|
| 190 |
+
const info = videosInfo[index];
|
| 191 |
+
if (!video || !info) return;
|
| 192 |
|
| 193 |
+
let globalTime = video.currentTime;
|
| 194 |
+
if (info.isSegmented) {
|
| 195 |
+
globalTime = video.currentTime - (info.segmentStart || 0);
|
| 196 |
+
}
|
| 197 |
+
lastVideoTimeRef.current = globalTime;
|
| 198 |
+
setCurrentTime(globalTime);
|
| 199 |
+
};
|
| 200 |
+
},
|
| 201 |
+
[videosInfo, setCurrentTime],
|
| 202 |
+
);
|
| 203 |
|
| 204 |
// Handle play click for segmented videos
|
| 205 |
const handlePlay = (video: HTMLVideoElement, info: VideoInfo) => {
|
|
|
|
| 302 |
}`}
|
| 303 |
muted
|
| 304 |
preload="auto"
|
| 305 |
+
crossOrigin="anonymous"
|
| 306 |
onPlay={(e) => handlePlay(e.currentTarget, info)}
|
| 307 |
+
onTimeUpdate={
|
| 308 |
+
isFirstVisible ? makeTimeUpdateHandler(idx) : undefined
|
| 309 |
+
}
|
| 310 |
>
|
| 311 |
<source src={info.url} type="video/mp4" />
|
| 312 |
Your browser does not support the video tag.
|
src/components/videos-player.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useEffect, useRef, useState } from "react";
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
|
@@ -13,24 +13,25 @@ type VideoPlayerProps = {
|
|
| 13 |
const videoCleanupHandlers = new WeakMap<HTMLVideoElement, () => void>();
|
| 14 |
const videoReadyHandlers = new WeakMap<HTMLVideoElement, EventListener>();
|
| 15 |
|
|
|
|
|
|
|
| 16 |
export const VideosPlayer = ({
|
| 17 |
videosInfo,
|
| 18 |
onVideosReady,
|
| 19 |
}: VideoPlayerProps) => {
|
| 20 |
const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
|
| 21 |
const videoRefs = useRef<HTMLVideoElement[]>([]);
|
| 22 |
-
// Hidden/enlarged state and hidden menu
|
| 23 |
const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
const firstVisibleIdx = videosInfo.findIndex(
|
| 26 |
-
(video) => !
|
| 27 |
);
|
| 28 |
-
// Count of visible videos
|
| 29 |
const visibleCount = videosInfo.filter(
|
| 30 |
-
(video) => !
|
| 31 |
).length;
|
| 32 |
const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
|
| 33 |
-
// Track previous hiddenVideos for comparison
|
| 34 |
const prevHiddenVideosRef = useRef<string[]>([]);
|
| 35 |
const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
| 36 |
const [showHiddenMenu, setShowHiddenMenu] = useState(false);
|
|
@@ -47,16 +48,16 @@ export const VideosPlayer = ({
|
|
| 47 |
videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
|
| 48 |
}, [videosInfo]);
|
| 49 |
|
| 50 |
-
// When videos get unhidden,
|
| 51 |
useEffect(() => {
|
| 52 |
-
// Find which videos were just unhidden
|
| 53 |
const prevHidden = prevHiddenVideosRef.current;
|
| 54 |
const newlyUnhidden = prevHidden.filter(
|
| 55 |
-
(filename) => !
|
| 56 |
);
|
| 57 |
if (newlyUnhidden.length > 0) {
|
|
|
|
| 58 |
videosInfo.forEach((video, idx) => {
|
| 59 |
-
if (
|
| 60 |
const ref = videoRefs.current[idx];
|
| 61 |
if (ref) {
|
| 62 |
ref.currentTime = currentTime;
|
|
@@ -68,7 +69,7 @@ export const VideosPlayer = ({
|
|
| 68 |
});
|
| 69 |
}
|
| 70 |
prevHiddenVideosRef.current = hiddenVideos;
|
| 71 |
-
}, [hiddenVideos, isPlaying, videosInfo, currentTime]);
|
| 72 |
|
| 73 |
// Check video codec support
|
| 74 |
useEffect(() => {
|
|
@@ -83,18 +84,17 @@ export const VideosPlayer = ({
|
|
| 83 |
checkCodecSupport();
|
| 84 |
}, []);
|
| 85 |
|
| 86 |
-
// Handle play/pause
|
| 87 |
useEffect(() => {
|
| 88 |
-
videoRefs.current.forEach((video) => {
|
| 89 |
-
if (video)
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
}
|
| 95 |
}
|
| 96 |
});
|
| 97 |
-
}, [isPlaying]);
|
| 98 |
|
| 99 |
// Minimize enlarged video on Escape key
|
| 100 |
useEffect(() => {
|
|
@@ -154,43 +154,45 @@ export const VideosPlayer = ({
|
|
| 154 |
|
| 155 |
videoRefs.current.forEach((video, index) => {
|
| 156 |
if (!video) return;
|
| 157 |
-
|
| 158 |
-
// Skip the primary video unless the time was changed externally
|
| 159 |
if (index === firstVisibleIdx && !isExternalSeek) return;
|
| 160 |
|
| 161 |
const videoInfo = videosInfo[index];
|
| 162 |
if (videoInfo?.isSegmented) {
|
| 163 |
const segmentStart = videoInfo.segmentStart || 0;
|
| 164 |
const segmentTime = segmentStart + currentTime;
|
| 165 |
-
if (Math.abs(video.currentTime - segmentTime) >
|
| 166 |
video.currentTime = segmentTime;
|
| 167 |
}
|
| 168 |
} else {
|
| 169 |
-
if (Math.abs(video.currentTime - currentTime) >
|
| 170 |
video.currentTime = currentTime;
|
| 171 |
}
|
| 172 |
}
|
| 173 |
});
|
| 174 |
-
}, [currentTime, videosInfo, firstVisibleIdx]);
|
| 175 |
-
|
| 176 |
-
//
|
| 177 |
-
const
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
// Handle video ready and setup segmentation
|
| 196 |
useEffect(() => {
|
|
@@ -400,9 +402,12 @@ export const VideosPlayer = ({
|
|
| 400 |
muted
|
| 401 |
loop
|
| 402 |
preload="auto"
|
|
|
|
| 403 |
className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
|
| 404 |
onTimeUpdate={
|
| 405 |
-
idx === firstVisibleIdx
|
|
|
|
|
|
|
| 406 |
}
|
| 407 |
style={isEnlarged ? { zIndex: 41 } : {}}
|
| 408 |
>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
| 4 |
import { useTime } from "../context/time-context";
|
| 5 |
import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
|
| 6 |
import type { VideoInfo } from "@/types";
|
|
|
|
| 13 |
const videoCleanupHandlers = new WeakMap<HTMLVideoElement, () => void>();
|
| 14 |
const videoReadyHandlers = new WeakMap<HTMLVideoElement, EventListener>();
|
| 15 |
|
| 16 |
+
const VIDEO_SYNC_TOLERANCE = 0.2;
|
| 17 |
+
|
| 18 |
export const VideosPlayer = ({
|
| 19 |
videosInfo,
|
| 20 |
onVideosReady,
|
| 21 |
}: VideoPlayerProps) => {
|
| 22 |
const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
|
| 23 |
const videoRefs = useRef<HTMLVideoElement[]>([]);
|
|
|
|
| 24 |
const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
|
| 25 |
+
|
| 26 |
+
const hiddenSet = useMemo(() => new Set(hiddenVideos), [hiddenVideos]);
|
| 27 |
+
|
| 28 |
const firstVisibleIdx = videosInfo.findIndex(
|
| 29 |
+
(video) => !hiddenSet.has(video.filename),
|
| 30 |
);
|
|
|
|
| 31 |
const visibleCount = videosInfo.filter(
|
| 32 |
+
(video) => !hiddenSet.has(video.filename),
|
| 33 |
).length;
|
| 34 |
const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
|
|
|
|
| 35 |
const prevHiddenVideosRef = useRef<string[]>([]);
|
| 36 |
const videoContainerRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
| 37 |
const [showHiddenMenu, setShowHiddenMenu] = useState(false);
|
|
|
|
| 48 |
videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
|
| 49 |
}, [videosInfo]);
|
| 50 |
|
| 51 |
+
// When videos get unhidden, sync their time and resume playback
|
| 52 |
useEffect(() => {
|
|
|
|
| 53 |
const prevHidden = prevHiddenVideosRef.current;
|
| 54 |
const newlyUnhidden = prevHidden.filter(
|
| 55 |
+
(filename) => !hiddenSet.has(filename),
|
| 56 |
);
|
| 57 |
if (newlyUnhidden.length > 0) {
|
| 58 |
+
const unhiddenNames = new Set(newlyUnhidden);
|
| 59 |
videosInfo.forEach((video, idx) => {
|
| 60 |
+
if (unhiddenNames.has(video.filename)) {
|
| 61 |
const ref = videoRefs.current[idx];
|
| 62 |
if (ref) {
|
| 63 |
ref.currentTime = currentTime;
|
|
|
|
| 69 |
});
|
| 70 |
}
|
| 71 |
prevHiddenVideosRef.current = hiddenVideos;
|
| 72 |
+
}, [hiddenVideos, hiddenSet, isPlaying, videosInfo, currentTime]);
|
| 73 |
|
| 74 |
// Check video codec support
|
| 75 |
useEffect(() => {
|
|
|
|
| 84 |
checkCodecSupport();
|
| 85 |
}, []);
|
| 86 |
|
| 87 |
+
// Handle play/pause — skip hidden videos to avoid wasting decoder time
|
| 88 |
useEffect(() => {
|
| 89 |
+
videoRefs.current.forEach((video, idx) => {
|
| 90 |
+
if (!video || hiddenSet.has(videosInfo[idx]?.filename)) return;
|
| 91 |
+
if (isPlaying) {
|
| 92 |
+
video.play().catch(() => console.error("Error playing video"));
|
| 93 |
+
} else {
|
| 94 |
+
video.pause();
|
|
|
|
| 95 |
}
|
| 96 |
});
|
| 97 |
+
}, [isPlaying, hiddenSet, videosInfo]);
|
| 98 |
|
| 99 |
// Minimize enlarged video on Escape key
|
| 100 |
useEffect(() => {
|
|
|
|
| 154 |
|
| 155 |
videoRefs.current.forEach((video, index) => {
|
| 156 |
if (!video) return;
|
| 157 |
+
if (hiddenSet.has(videosInfo[index]?.filename)) return;
|
|
|
|
| 158 |
if (index === firstVisibleIdx && !isExternalSeek) return;
|
| 159 |
|
| 160 |
const videoInfo = videosInfo[index];
|
| 161 |
if (videoInfo?.isSegmented) {
|
| 162 |
const segmentStart = videoInfo.segmentStart || 0;
|
| 163 |
const segmentTime = segmentStart + currentTime;
|
| 164 |
+
if (Math.abs(video.currentTime - segmentTime) > VIDEO_SYNC_TOLERANCE) {
|
| 165 |
video.currentTime = segmentTime;
|
| 166 |
}
|
| 167 |
} else {
|
| 168 |
+
if (Math.abs(video.currentTime - currentTime) > VIDEO_SYNC_TOLERANCE) {
|
| 169 |
video.currentTime = currentTime;
|
| 170 |
}
|
| 171 |
}
|
| 172 |
});
|
| 173 |
+
}, [currentTime, videosInfo, firstVisibleIdx, hiddenSet]);
|
| 174 |
+
|
| 175 |
+
// Stable per-index timeupdate handlers avoid findIndex scan on every event
|
| 176 |
+
const makeTimeUpdateHandler = useCallback(
|
| 177 |
+
(index: number) => {
|
| 178 |
+
return () => {
|
| 179 |
+
const video = videoRefs.current[index];
|
| 180 |
+
if (!video || !video.duration) return;
|
| 181 |
+
const videoInfo = videosInfo[index];
|
| 182 |
+
|
| 183 |
+
if (videoInfo?.isSegmented) {
|
| 184 |
+
const segmentStart = videoInfo.segmentStart || 0;
|
| 185 |
+
const globalTime = Math.max(0, video.currentTime - segmentStart);
|
| 186 |
+
lastVideoTimeRef.current = globalTime;
|
| 187 |
+
setCurrentTime(globalTime);
|
| 188 |
+
} else {
|
| 189 |
+
lastVideoTimeRef.current = video.currentTime;
|
| 190 |
+
setCurrentTime(video.currentTime);
|
| 191 |
+
}
|
| 192 |
+
};
|
| 193 |
+
},
|
| 194 |
+
[videosInfo, setCurrentTime],
|
| 195 |
+
);
|
| 196 |
|
| 197 |
// Handle video ready and setup segmentation
|
| 198 |
useEffect(() => {
|
|
|
|
| 402 |
muted
|
| 403 |
loop
|
| 404 |
preload="auto"
|
| 405 |
+
crossOrigin="anonymous"
|
| 406 |
className={`w-full object-contain ${isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""}`}
|
| 407 |
onTimeUpdate={
|
| 408 |
+
idx === firstVisibleIdx
|
| 409 |
+
? makeTimeUpdateHandler(idx)
|
| 410 |
+
: undefined
|
| 411 |
}
|
| 412 |
style={isEnlarged ? { zIndex: 41 } : {}}
|
| 413 |
>
|
src/context/time-context.tsx
CHANGED
|
@@ -4,9 +4,9 @@ import React, {
|
|
| 4 |
useRef,
|
| 5 |
useState,
|
| 6 |
useCallback,
|
|
|
|
| 7 |
} from "react";
|
| 8 |
|
| 9 |
-
// The shape of our context
|
| 10 |
type TimeContextType = {
|
| 11 |
currentTime: number;
|
| 12 |
setCurrentTime: (t: number) => void;
|
|
@@ -25,22 +25,56 @@ export const useTime = () => {
|
|
| 25 |
return ctx;
|
| 26 |
};
|
| 27 |
|
|
|
|
|
|
|
| 28 |
export const TimeProvider: React.FC<{
|
| 29 |
children: React.ReactNode;
|
| 30 |
duration: number;
|
| 31 |
}> = ({ children, duration: initialDuration }) => {
|
| 32 |
-
const [currentTime,
|
| 33 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 34 |
const [duration, setDuration] = useState(initialDuration);
|
| 35 |
const listeners = useRef<Set<(t: number) => void>>(new Set());
|
| 36 |
|
| 37 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const updateTime = useCallback((t: number) => {
|
| 39 |
-
|
| 40 |
listeners.current.forEach((fn) => fn(t));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}, []);
|
| 42 |
|
| 43 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
const subscribe = useCallback((cb: (t: number) => void) => {
|
| 45 |
listeners.current.add(cb);
|
| 46 |
return () => listeners.current.delete(cb);
|
|
|
|
| 4 |
useRef,
|
| 5 |
useState,
|
| 6 |
useCallback,
|
| 7 |
+
useEffect,
|
| 8 |
} from "react";
|
| 9 |
|
|
|
|
| 10 |
type TimeContextType = {
|
| 11 |
currentTime: number;
|
| 12 |
setCurrentTime: (t: number) => void;
|
|
|
|
| 25 |
return ctx;
|
| 26 |
};
|
| 27 |
|
| 28 |
+
const TIME_RENDER_THROTTLE_MS = 80;
|
| 29 |
+
|
| 30 |
export const TimeProvider: React.FC<{
|
| 31 |
children: React.ReactNode;
|
| 32 |
duration: number;
|
| 33 |
}> = ({ children, duration: initialDuration }) => {
|
| 34 |
+
const [currentTime, setCurrentTimeState] = useState(0);
|
| 35 |
const [isPlaying, setIsPlaying] = useState(false);
|
| 36 |
const [duration, setDuration] = useState(initialDuration);
|
| 37 |
const listeners = useRef<Set<(t: number) => void>>(new Set());
|
| 38 |
|
| 39 |
+
// Keep the authoritative time in a ref so subscribers and sync effects
|
| 40 |
+
// always see the latest value without waiting for a React render cycle.
|
| 41 |
+
const timeRef = useRef(0);
|
| 42 |
+
const rafId = useRef<number | null>(null);
|
| 43 |
+
const lastRenderTime = useRef(0);
|
| 44 |
+
|
| 45 |
const updateTime = useCallback((t: number) => {
|
| 46 |
+
timeRef.current = t;
|
| 47 |
listeners.current.forEach((fn) => fn(t));
|
| 48 |
+
|
| 49 |
+
// Throttle React state updates — during playback, timeupdate fires ~4×/sec
|
| 50 |
+
// per video. Coalescing into rAF + a minimum interval avoids cascading
|
| 51 |
+
// re-renders across PlaybackBar, charts, etc.
|
| 52 |
+
if (rafId.current === null) {
|
| 53 |
+
rafId.current = requestAnimationFrame(() => {
|
| 54 |
+
rafId.current = null;
|
| 55 |
+
const now = performance.now();
|
| 56 |
+
if (now - lastRenderTime.current >= TIME_RENDER_THROTTLE_MS) {
|
| 57 |
+
lastRenderTime.current = now;
|
| 58 |
+
setCurrentTimeState(timeRef.current);
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
}, []);
|
| 63 |
|
| 64 |
+
// Flush any pending rAF on unmount
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
return () => {
|
| 67 |
+
if (rafId.current !== null) cancelAnimationFrame(rafId.current);
|
| 68 |
+
};
|
| 69 |
+
}, []);
|
| 70 |
+
|
| 71 |
+
// When playback stops, flush the exact final time so the UI matches
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (!isPlaying) {
|
| 74 |
+
setCurrentTimeState(timeRef.current);
|
| 75 |
+
}
|
| 76 |
+
}, [isPlaying]);
|
| 77 |
+
|
| 78 |
const subscribe = useCallback((cb: (t: number) => void) => {
|
| 79 |
listeners.current.add(cb);
|
| 80 |
return () => listeners.current.delete(cb);
|