mishig HF Staff commited on
Commit
7b87ba1
·
verified ·
1 Parent(s): 5e6e3e2

Sync from GitHub via hub-sync

Browse files
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) => !hiddenVideos.includes(video.filename),
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 === videosInfo.length && onVideosReady) {
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 && !hiddenVideos.includes(videosInfo[idx].filename)) {
134
- if (isPlaying) {
135
- video.play().catch((e) => {
136
- if (e.name !== "AbortError") {
137
- console.error("Error playing video");
138
- }
139
- });
140
- } else {
141
- video.pause();
142
- }
143
  }
144
  });
145
- }, [isPlaying, videosReady, hiddenVideos, videosInfo]);
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 (hiddenVideos.includes(videosInfo[index].filename)) return;
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 (Math.abs(video.currentTime - targetTime) > 0.2) {
 
 
 
170
  video.currentTime = targetTime;
171
  }
172
  });
173
- }, [currentTime, videosInfo, videosReady, hiddenVideos, firstVisibleIdx]);
174
 
175
- // Handle time update from first visible video
176
- const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
177
- const video = e.target as HTMLVideoElement;
178
- const videoIndex = videoRefs.current.findIndex((ref) => ref === video);
179
- const info = videosInfo[videoIndex];
 
 
180
 
181
- if (info) {
182
- let globalTime = video.currentTime;
183
- if (info.isSegmented) {
184
- globalTime = video.currentTime - (info.segmentStart || 0);
185
- }
186
- lastVideoTimeRef.current = globalTime;
187
- setCurrentTime(globalTime);
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={isFirstVisible ? handleTimeUpdate : undefined}
 
 
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
- // Find the index of the first visible (not hidden) video
 
 
25
  const firstVisibleIdx = videosInfo.findIndex(
26
- (video) => !hiddenVideos.includes(video.filename),
27
  );
28
- // Count of visible videos
29
  const visibleCount = videosInfo.filter(
30
- (video) => !hiddenVideos.includes(video.filename),
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, start playing them if it was playing
51
  useEffect(() => {
52
- // Find which videos were just unhidden
53
  const prevHidden = prevHiddenVideosRef.current;
54
  const newlyUnhidden = prevHidden.filter(
55
- (filename) => !hiddenVideos.includes(filename),
56
  );
57
  if (newlyUnhidden.length > 0) {
 
58
  videosInfo.forEach((video, idx) => {
59
- if (newlyUnhidden.includes(video.filename)) {
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
- if (isPlaying) {
91
- video.play().catch(() => console.error("Error playing video"));
92
- } else {
93
- video.pause();
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) > 0.2) {
166
  video.currentTime = segmentTime;
167
  }
168
  } else {
169
- if (Math.abs(video.currentTime - currentTime) > 0.2) {
170
  video.currentTime = currentTime;
171
  }
172
  }
173
  });
174
- }, [currentTime, videosInfo, firstVisibleIdx]);
175
-
176
- // Handle time update
177
- const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
178
- const video = e.target as HTMLVideoElement;
179
- if (video && video.duration) {
180
- const videoIndex = videoRefs.current.findIndex((ref) => ref === video);
181
- const videoInfo = videosInfo[videoIndex];
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
 
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 ? handleTimeUpdate : undefined
 
 
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, setCurrentTime] = useState(0);
33
  const [isPlaying, setIsPlaying] = useState(false);
34
  const [duration, setDuration] = useState(initialDuration);
35
  const listeners = useRef<Set<(t: number) => void>>(new Set());
36
 
37
- // Call this to update time and notify all listeners
 
 
 
 
 
38
  const updateTime = useCallback((t: number) => {
39
- setCurrentTime(t);
40
  listeners.current.forEach((fn) => fn(t));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }, []);
42
 
43
- // Components can subscribe to time changes (for imperative updates)
 
 
 
 
 
 
 
 
 
 
 
 
 
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);