aractingi commited on
Commit
bb094c2
·
1 Parent(s): fe9b8aa

working better visualization

Browse files
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx CHANGED
@@ -3,7 +3,7 @@
3
  import { useState, useEffect, useRef } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
- import { OptimizedVideosPlayer } from "@/components/optimized-videos-player";
7
  import DataRecharts from "@/components/data-recharts";
8
  import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
@@ -223,7 +223,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: any; org?: string; d
223
 
224
  {/* Videos */}
225
  {videosInfo.length && (
226
- <OptimizedVideosPlayer
227
  videosInfo={videosInfo}
228
  onVideosReady={() => setVideosReady(true)}
229
  />
 
3
  import { useState, useEffect, useRef } from "react";
4
  import { useRouter, useSearchParams } from "next/navigation";
5
  import { postParentMessageWithParams } from "@/utils/postParentMessage";
6
+ import { SimpleVideosPlayer } from "@/components/simple-videos-player";
7
  import DataRecharts from "@/components/data-recharts";
8
  import PlaybackBar from "@/components/playback-bar";
9
  import { TimeProvider, useTime } from "@/context/time-context";
 
223
 
224
  {/* Videos */}
225
  {videosInfo.length && (
226
+ <SimpleVideosPlayer
227
  videosInfo={videosInfo}
228
  onVideosReady={() => setVideosReady(true)}
229
  />
src/components/optimized-videos-player.tsx DELETED
@@ -1,543 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useRef, useState, useCallback, useMemo } from "react";
4
- import { useTime } from "../context/time-context";
5
- import { FaExpand, FaCompress, FaTimes, FaEye } from "react-icons/fa";
6
-
7
- type VideoInfo = {
8
- filename: string;
9
- url: string;
10
- isSegmented?: boolean;
11
- segmentStart?: number;
12
- segmentEnd?: number;
13
- segmentDuration?: number;
14
- };
15
-
16
- type VideoPlayerProps = {
17
- videosInfo: VideoInfo[];
18
- onVideosReady?: () => void;
19
- };
20
-
21
- // Global video cache to persist across component instances
22
- const globalVideoCache = new Map<string, HTMLVideoElement>();
23
- const globalPreloadCache = new Map<string, Promise<void>>();
24
-
25
- // Helper to preload a video URL
26
- async function preloadVideo(url: string): Promise<void> {
27
- // Check if already preloading or preloaded
28
- if (globalPreloadCache.has(url)) {
29
- return globalPreloadCache.get(url)!;
30
- }
31
-
32
- const preloadPromise = new Promise<void>((resolve, reject) => {
33
- let video = globalVideoCache.get(url);
34
-
35
- if (!video) {
36
- video = document.createElement("video");
37
- video.src = url;
38
- video.muted = true;
39
- video.loop = true;
40
- video.preload = "auto";
41
- globalVideoCache.set(url, video);
42
- }
43
-
44
- if (video.readyState >= 4) {
45
- resolve();
46
- } else {
47
- const handleCanPlay = () => {
48
- video!.removeEventListener("canplaythrough", handleCanPlay);
49
- video!.removeEventListener("error", handleError);
50
- resolve();
51
- };
52
-
53
- const handleError = () => {
54
- video!.removeEventListener("canplaythrough", handleCanPlay);
55
- video!.removeEventListener("error", handleError);
56
- reject(new Error(`Failed to preload video: ${url}`));
57
- };
58
-
59
- video.addEventListener("canplaythrough", handleCanPlay);
60
- video.addEventListener("error", handleError);
61
-
62
- // Force load
63
- video.load();
64
- }
65
- });
66
-
67
- globalPreloadCache.set(url, preloadPromise);
68
- return preloadPromise;
69
- }
70
-
71
- export const OptimizedVideosPlayer = ({
72
- videosInfo,
73
- onVideosReady,
74
- }: VideoPlayerProps) => {
75
- const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
76
- const containerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
77
- const videoElementsRef = useRef<Map<string, HTMLVideoElement>>(new Map());
78
- const [hiddenVideos, setHiddenVideos] = useState<string[]>([]);
79
- const [enlargedVideo, setEnlargedVideo] = useState<string | null>(null);
80
- const [showHiddenMenu, setShowHiddenMenu] = useState(false);
81
- const hiddenMenuRef = useRef<HTMLDivElement | null>(null);
82
- const showHiddenBtnRef = useRef<HTMLButtonElement | null>(null);
83
- const [videoCodecError, setVideoCodecError] = useState(false);
84
- const [videosReady, setVideosReady] = useState(false);
85
- const readyCountRef = useRef(0);
86
-
87
- // Track first visible video for time sync
88
- const firstVisibleIdx = videosInfo.findIndex(
89
- (video) => !hiddenVideos.includes(video.filename)
90
- );
91
- const visibleCount = videosInfo.filter(
92
- (video) => !hiddenVideos.includes(video.filename)
93
- ).length;
94
-
95
- // Get unique video URLs for caching
96
- const uniqueVideoUrls = useMemo(() => {
97
- const urls = new Set<string>();
98
- videosInfo.forEach(info => urls.add(info.url));
99
- return Array.from(urls);
100
- }, [videosInfo]);
101
-
102
- // Check codec support
103
- useEffect(() => {
104
- const checkCodecSupport = () => {
105
- const dummyVideo = document.createElement("video");
106
- const canPlayVideos = dummyVideo.canPlayType(
107
- 'video/mp4; codecs="av01.0.05M.08"'
108
- );
109
- setVideoCodecError(!canPlayVideos);
110
- };
111
- checkCodecSupport();
112
- }, []);
113
-
114
- // Initialize and reuse video elements
115
- useEffect(() => {
116
- readyCountRef.current = 0;
117
- setVideosReady(false);
118
- const videoElements = new Map<string, HTMLVideoElement>();
119
-
120
- // For each video info, create a video element
121
- videosInfo.forEach((info) => {
122
- const video = document.createElement("video");
123
- video.src = info.url;
124
- video.muted = true;
125
- video.loop = true;
126
- video.preload = "auto";
127
-
128
- // Mark in cache if not already there
129
- if (!globalVideoCache.has(info.url)) {
130
- globalVideoCache.set(info.url, video);
131
- }
132
-
133
- videoElements.set(info.filename, video);
134
- });
135
-
136
- videoElementsRef.current = videoElements;
137
-
138
- // Setup video ready handlers
139
- const checkAllReady = () => {
140
- readyCountRef.current++;
141
- if (readyCountRef.current === videosInfo.length && !videosReady) {
142
- setVideosReady(true);
143
- if (onVideosReady) {
144
- onVideosReady();
145
- setIsPlaying(true);
146
- }
147
- }
148
- };
149
-
150
- videoElements.forEach((video, filename) => {
151
- const info = videosInfo.find(v => v.filename === filename);
152
- if (!info) return;
153
-
154
- const handleCanPlay = () => {
155
- // Setup initial segment time if needed
156
- if (info.isSegmented) {
157
- const start = info.segmentStart || 0;
158
- if (Math.abs(video.currentTime - start) > 0.1) {
159
- video.currentTime = start;
160
- }
161
- }
162
- checkAllReady();
163
- };
164
-
165
- if (video.readyState >= 4) {
166
- // Use setTimeout to avoid synchronous state updates
167
- setTimeout(() => handleCanPlay(), 0);
168
- } else {
169
- video.addEventListener("canplaythrough", handleCanPlay, { once: true });
170
- // Also listen for loadeddata as a fallback
171
- video.addEventListener("loadeddata", () => {
172
- if (video.readyState >= 3) {
173
- handleCanPlay();
174
- }
175
- }, { once: true });
176
- }
177
- });
178
-
179
- return () => {
180
- // Don't destroy global cache, just clear references
181
- videoElementsRef.current.clear();
182
- };
183
- }, [videosInfo, onVideosReady, setIsPlaying]);
184
-
185
- // Handle segment looping
186
- useEffect(() => {
187
- const videos = videoElementsRef.current;
188
- const handlers = new Map<string, (e: Event) => void>();
189
-
190
- videos.forEach((video, filename) => {
191
- const info = videosInfo.find(v => v.filename === filename);
192
- if (!info || !info.isSegmented || hiddenVideos.includes(filename)) return;
193
-
194
- const handleTimeUpdate = () => {
195
- // Only handle if video is connected to DOM
196
- if (!video.isConnected) return;
197
-
198
- const segmentEnd = info.segmentEnd || video.duration;
199
- const segmentStart = info.segmentStart || 0;
200
-
201
- if (video.currentTime >= segmentEnd - 0.1) {
202
- video.currentTime = segmentStart;
203
- if (!isPlaying) {
204
- video.pause();
205
- }
206
- }
207
- };
208
-
209
- video.addEventListener("timeupdate", handleTimeUpdate);
210
- handlers.set(filename, handleTimeUpdate);
211
- });
212
-
213
- return () => {
214
- videos.forEach((video, filename) => {
215
- const handler = handlers.get(filename);
216
- if (handler) {
217
- video.removeEventListener("timeupdate", handler);
218
- }
219
- });
220
- };
221
- }, [videosInfo, isPlaying, hiddenVideos]);
222
-
223
- // Handle play/pause
224
- useEffect(() => {
225
- if (!videosReady) return;
226
-
227
- const playPromises: Promise<void>[] = [];
228
-
229
- videoElementsRef.current.forEach((video, filename) => {
230
- if (hiddenVideos.includes(filename)) return;
231
-
232
- if (isPlaying) {
233
- // Only play if video is in the document
234
- if (video.isConnected) {
235
- const playPromise = video.play().catch(e => {
236
- // Ignore interruption errors
237
- if (e.name !== 'AbortError') {
238
- console.error("Error playing video:", e);
239
- }
240
- });
241
- playPromises.push(playPromise);
242
- }
243
- } else {
244
- video.pause();
245
- }
246
- });
247
-
248
- // Cleanup function to handle component unmount
249
- return () => {
250
- videoElementsRef.current.forEach((video) => {
251
- if (video.isConnected) {
252
- video.pause();
253
- }
254
- });
255
- };
256
- }, [isPlaying, videosReady, hiddenVideos]);
257
-
258
- // Sync video times
259
- useEffect(() => {
260
- if (!videosReady) return;
261
-
262
- videoElementsRef.current.forEach((video, filename) => {
263
- const info = videosInfo.find(v => v.filename === filename);
264
- if (!info || hiddenVideos.includes(filename)) return;
265
-
266
- let targetTime = currentTime;
267
-
268
- if (info.isSegmented) {
269
- const segmentStart = info.segmentStart || 0;
270
- targetTime = segmentStart + currentTime;
271
- }
272
-
273
- if (Math.abs(video.currentTime - targetTime) > 0.2) {
274
- video.currentTime = targetTime;
275
- }
276
- });
277
- }, [currentTime, videosInfo, videosReady, hiddenVideos]);
278
-
279
- // Handle time update from first visible video
280
- const handleTimeUpdate = useCallback((e: Event) => {
281
- const video = e.target as HTMLVideoElement;
282
-
283
- // Ensure video is still connected and playing
284
- if (!video.isConnected || video.paused) return;
285
-
286
- const filename = Array.from(videoElementsRef.current.entries())
287
- .find(([_, v]) => v === video)?.[0];
288
-
289
- if (!filename) return;
290
-
291
- const info = videosInfo.find(v => v.filename === filename);
292
- if (!info) return;
293
-
294
- let globalTime = video.currentTime;
295
-
296
- if (info.isSegmented) {
297
- const segmentStart = info.segmentStart || 0;
298
- globalTime = Math.max(0, video.currentTime - segmentStart);
299
- }
300
-
301
- setCurrentTime(globalTime);
302
- }, [videosInfo, setCurrentTime]);
303
-
304
- // Handle escape key for enlarged video
305
- useEffect(() => {
306
- if (!enlargedVideo) return;
307
-
308
- const handleKeyDown = (e: KeyboardEvent) => {
309
- if (e.key === "Escape") {
310
- setEnlargedVideo(null);
311
- }
312
- };
313
-
314
- window.addEventListener("keydown", handleKeyDown);
315
- return () => window.removeEventListener("keydown", handleKeyDown);
316
- }, [enlargedVideo]);
317
-
318
- // Handle outside click for hidden menu
319
- useEffect(() => {
320
- if (!showHiddenMenu) return;
321
-
322
- const handleClick = (e: MouseEvent) => {
323
- const menu = hiddenMenuRef.current;
324
- const btn = showHiddenBtnRef.current;
325
- if (
326
- menu && !menu.contains(e.target as Node) &&
327
- btn && !btn.contains(e.target as Node)
328
- ) {
329
- setShowHiddenMenu(false);
330
- }
331
- };
332
-
333
- document.addEventListener("mousedown", handleClick);
334
- return () => document.removeEventListener("mousedown", handleClick);
335
- }, [showHiddenMenu]);
336
-
337
- // Preload adjacent videos
338
- useEffect(() => {
339
- // Get current video URLs
340
- const currentUrls = new Set(videosInfo.map(v => v.url));
341
-
342
- // Preload logic would go here - you'd need to pass in adjacent episode info
343
- // For now, just ensure current videos are loaded
344
- currentUrls.forEach(url => {
345
- preloadVideo(url).catch(err =>
346
- console.warn(`Failed to preload video ${url}:`, err)
347
- );
348
- });
349
- }, [videosInfo]);
350
-
351
- // Attach/detach videos from DOM
352
- useEffect(() => {
353
- const containers = containerRefs.current;
354
- const videos = videoElementsRef.current;
355
-
356
- // Attach videos to their containers
357
- videos.forEach((video, filename) => {
358
- const container = containers.get(filename);
359
- if (container && !hiddenVideos.includes(filename)) {
360
- // Only append if not already a child
361
- if (video.parentElement !== container) {
362
- // Pause before moving to avoid interruption
363
- const wasPlaying = !video.paused;
364
- if (wasPlaying) {
365
- video.pause();
366
- }
367
-
368
- container.appendChild(video);
369
-
370
- // Resume if was playing
371
- if (wasPlaying && isPlaying) {
372
- video.play().catch(e => {
373
- if (e.name !== 'AbortError') {
374
- console.error("Error resuming video:", e);
375
- }
376
- });
377
- }
378
- }
379
-
380
- // Update classes and event handlers
381
- const isEnlarged = enlargedVideo === filename;
382
- const isFirstVisible = Array.from(videos.keys()).indexOf(filename) === firstVisibleIdx;
383
-
384
- video.className = `w-full object-contain ${
385
- isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
386
- }`;
387
- video.style.zIndex = isEnlarged ? '41' : '';
388
- video.ontimeupdate = isFirstVisible ? handleTimeUpdate : null;
389
- }
390
- });
391
-
392
- // Cleanup function
393
- return () => {
394
- videos.forEach((video) => {
395
- if (video.isConnected) {
396
- video.pause();
397
- }
398
- });
399
- };
400
- }, [videosInfo, hiddenVideos, enlargedVideo, firstVisibleIdx, handleTimeUpdate, isPlaying]);
401
-
402
- // Render video element
403
- const renderVideo = (info: VideoInfo, index: number) => {
404
- if (hiddenVideos.includes(info.filename)) return null;
405
-
406
- const isEnlarged = enlargedVideo === info.filename;
407
-
408
- return (
409
- <div
410
- key={info.filename}
411
- ref={(el) => {
412
- if (el) containerRefs.current.set(info.filename, el);
413
- else containerRefs.current.delete(info.filename);
414
- }}
415
- className={`${
416
- isEnlarged
417
- ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center"
418
- : "max-w-96"
419
- }`}
420
- style={isEnlarged ? { height: "100vh", width: "100vw" } : {}}
421
- >
422
- <p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
423
- <span>{info.filename}</span>
424
- <span className="flex gap-1">
425
- <button
426
- title={isEnlarged ? "Minimize" : "Enlarge"}
427
- className="ml-2 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
428
- onClick={() => setEnlargedVideo(isEnlarged ? null : info.filename)}
429
- >
430
- {isEnlarged ? <FaCompress /> : <FaExpand />}
431
- </button>
432
- <button
433
- title="Hide Video"
434
- className="ml-1 p-1 hover:bg-slate-700 rounded focus:outline-none focus:ring-0"
435
- onClick={() => setHiddenVideos(prev => [...prev, info.filename])}
436
- disabled={visibleCount === 1}
437
- >
438
- <FaTimes />
439
- </button>
440
- </span>
441
- </p>
442
- <div
443
- ref={(container) => {
444
- if (container) {
445
- containerRefs.current.set(info.filename, container);
446
- }
447
- }}
448
- className="video-container"
449
- />
450
- </div>
451
- );
452
- };
453
-
454
- return (
455
- <>
456
- {/* Error message */}
457
- {videoCodecError && (
458
- <div className="font-medium text-orange-700">
459
- <p>
460
- Videos could NOT play because{" "}
461
- <a
462
- href="https://en.wikipedia.org/wiki/AV1"
463
- target="_blank"
464
- className="underline"
465
- >
466
- AV1
467
- </a>{" "}
468
- decoding is not available on your browser.
469
- </p>
470
- <ul className="list-inside list-decimal">
471
- <li>
472
- If iPhone:{" "}
473
- <span className="italic">
474
- It is supported with A17 chip or higher.
475
- </span>
476
- </li>
477
- <li>
478
- If Mac with Safari:{" "}
479
- <span className="italic">
480
- It is supported on most browsers except Safari with M1 chip or
481
- higher and on Safari with M3 chip or higher.
482
- </span>
483
- </li>
484
- <li>
485
- Other:{" "}
486
- <span className="italic">
487
- Contact the maintainers on LeRobot discord channel:
488
- </span>
489
- <a
490
- href="https://discord.com/invite/s3KuuzsPFb"
491
- target="_blank"
492
- className="underline"
493
- >
494
- https://discord.com/invite/s3KuuzsPFb
495
- </a>
496
- </li>
497
- </ul>
498
- </div>
499
- )}
500
-
501
- {/* Show Hidden Videos Button */}
502
- {hiddenVideos.length > 0 && (
503
- <div className="relative">
504
- <button
505
- ref={showHiddenBtnRef}
506
- 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"
507
- onClick={() => setShowHiddenMenu(prev => !prev)}
508
- >
509
- <FaEye /> Show Hidden Videos ({hiddenVideos.length})
510
- </button>
511
- {showHiddenMenu && (
512
- <div
513
- ref={hiddenMenuRef}
514
- className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50"
515
- >
516
- <div className="mb-2 text-xs text-slate-300">
517
- Restore hidden videos:
518
- </div>
519
- {hiddenVideos.map((filename) => (
520
- <button
521
- key={filename}
522
- className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
523
- onClick={() =>
524
- setHiddenVideos(prev => prev.filter(v => v !== filename))
525
- }
526
- >
527
- {filename}
528
- </button>
529
- ))}
530
- </div>
531
- )}
532
- </div>
533
- )}
534
-
535
- {/* Videos */}
536
- <div className="flex flex-wrap gap-x-2 gap-y-6">
537
- {videosInfo.map((video, idx) => renderVideo(video, idx))}
538
- </div>
539
- </>
540
- );
541
- };
542
-
543
- export default OptimizedVideosPlayer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/simple-videos-player.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+ type VideoInfo = {
8
+ filename: string;
9
+ url: string;
10
+ isSegmented?: boolean;
11
+ segmentStart?: number;
12
+ segmentEnd?: number;
13
+ segmentDuration?: number;
14
+ };
15
+
16
+ type VideoPlayerProps = {
17
+ videosInfo: VideoInfo[];
18
+ onVideosReady?: () => void;
19
+ };
20
+
21
+ export const SimpleVideosPlayer = ({
22
+ videosInfo,
23
+ onVideosReady,
24
+ }: VideoPlayerProps) => {
25
+ const { currentTime, setCurrentTime, isPlaying, setIsPlaying } = useTime();
26
+ const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
27
+ const [hiddenVideos, setHiddenVideos] = React.useState<string[]>([]);
28
+ const [enlargedVideo, setEnlargedVideo] = React.useState<string | null>(null);
29
+ const [showHiddenMenu, setShowHiddenMenu] = React.useState(false);
30
+ const [videosReady, setVideosReady] = React.useState(false);
31
+
32
+ const firstVisibleIdx = videosInfo.findIndex(
33
+ (video) => !hiddenVideos.includes(video.filename)
34
+ );
35
+
36
+ // Initialize video refs array
37
+ useEffect(() => {
38
+ videoRefs.current = videoRefs.current.slice(0, videosInfo.length);
39
+ }, [videosInfo.length]);
40
+
41
+ // Handle videos ready
42
+ useEffect(() => {
43
+ let readyCount = 0;
44
+
45
+ const checkReady = () => {
46
+ readyCount++;
47
+ if (readyCount === videosInfo.length && onVideosReady) {
48
+ setVideosReady(true);
49
+ onVideosReady();
50
+ setIsPlaying(true);
51
+ }
52
+ };
53
+
54
+ videoRefs.current.forEach((video, index) => {
55
+ if (video) {
56
+ const info = videosInfo[index];
57
+
58
+ // Setup segment boundaries
59
+ if (info.isSegmented) {
60
+ const handleTimeUpdate = () => {
61
+ const segmentEnd = info.segmentEnd || video.duration;
62
+ const segmentStart = info.segmentStart || 0;
63
+
64
+ if (video.currentTime >= segmentEnd - 0.05) {
65
+ video.currentTime = segmentStart;
66
+ }
67
+ };
68
+
69
+ const handleLoadedData = () => {
70
+ video.currentTime = info.segmentStart || 0;
71
+ checkReady();
72
+ };
73
+
74
+ video.addEventListener('timeupdate', handleTimeUpdate);
75
+ video.addEventListener('loadeddata', handleLoadedData);
76
+
77
+ // Store cleanup
78
+ (video as any)._segmentHandlers = () => {
79
+ video.removeEventListener('timeupdate', handleTimeUpdate);
80
+ video.removeEventListener('loadeddata', handleLoadedData);
81
+ };
82
+ } else {
83
+ video.addEventListener('canplaythrough', checkReady, { once: true });
84
+ }
85
+ }
86
+ });
87
+
88
+ return () => {
89
+ videoRefs.current.forEach((video) => {
90
+ if (video && (video as any)._segmentHandlers) {
91
+ (video as any)._segmentHandlers();
92
+ }
93
+ });
94
+ };
95
+ }, [videosInfo, onVideosReady, setIsPlaying]);
96
+
97
+ // Handle play/pause
98
+ useEffect(() => {
99
+ if (!videosReady) return;
100
+
101
+ videoRefs.current.forEach((video, idx) => {
102
+ if (video && !hiddenVideos.includes(videosInfo[idx].filename)) {
103
+ if (isPlaying) {
104
+ video.play().catch(e => {
105
+ if (e.name !== 'AbortError') {
106
+ console.error("Error playing video:", e);
107
+ }
108
+ });
109
+ } else {
110
+ video.pause();
111
+ }
112
+ }
113
+ });
114
+ }, [isPlaying, videosReady, hiddenVideos, videosInfo]);
115
+
116
+ // Sync video times
117
+ useEffect(() => {
118
+ if (!videosReady) return;
119
+
120
+ videoRefs.current.forEach((video, index) => {
121
+ if (video && !hiddenVideos.includes(videosInfo[index].filename)) {
122
+ const info = videosInfo[index];
123
+ let targetTime = currentTime;
124
+
125
+ if (info.isSegmented) {
126
+ targetTime = (info.segmentStart || 0) + currentTime;
127
+ }
128
+
129
+ if (Math.abs(video.currentTime - targetTime) > 0.2) {
130
+ video.currentTime = targetTime;
131
+ }
132
+ }
133
+ });
134
+ }, [currentTime, videosInfo, videosReady, hiddenVideos]);
135
+
136
+ // Handle time update from first visible video
137
+ const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
138
+ const video = e.target as HTMLVideoElement;
139
+ const videoIndex = videoRefs.current.findIndex(ref => ref === video);
140
+ const info = videosInfo[videoIndex];
141
+
142
+ if (info) {
143
+ let globalTime = video.currentTime;
144
+ if (info.isSegmented) {
145
+ globalTime = video.currentTime - (info.segmentStart || 0);
146
+ }
147
+ setCurrentTime(globalTime);
148
+ }
149
+ };
150
+
151
+ // Handle play click for segmented videos
152
+ const handlePlay = (video: HTMLVideoElement, info: VideoInfo) => {
153
+ if (info.isSegmented) {
154
+ const segmentStart = info.segmentStart || 0;
155
+ const segmentEnd = info.segmentEnd || video.duration;
156
+
157
+ if (video.currentTime < segmentStart || video.currentTime >= segmentEnd) {
158
+ video.currentTime = segmentStart;
159
+ }
160
+ }
161
+ video.play();
162
+ };
163
+
164
+ return (
165
+ <>
166
+ {/* Hidden videos menu */}
167
+ {hiddenVideos.length > 0 && (
168
+ <div className="relative mb-4">
169
+ <button
170
+ 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"
171
+ onClick={() => setShowHiddenMenu(!showHiddenMenu)}
172
+ >
173
+ <FaEye /> Show Hidden Videos ({hiddenVideos.length})
174
+ </button>
175
+ {showHiddenMenu && (
176
+ <div className="absolute left-0 mt-2 w-max rounded border border-slate-500 bg-slate-900 shadow-lg p-2 z-50">
177
+ <div className="mb-2 text-xs text-slate-300">
178
+ Restore hidden videos:
179
+ </div>
180
+ {hiddenVideos.map((filename) => (
181
+ <button
182
+ key={filename}
183
+ className="block w-full text-left px-2 py-1 rounded hover:bg-slate-700 text-slate-100"
184
+ onClick={() => setHiddenVideos(prev => prev.filter(v => v !== filename))}
185
+ >
186
+ {filename}
187
+ </button>
188
+ ))}
189
+ </div>
190
+ )}
191
+ </div>
192
+ )}
193
+
194
+ {/* Videos */}
195
+ <div className="flex flex-wrap gap-x-2 gap-y-6">
196
+ {videosInfo.map((info, idx) => {
197
+ if (hiddenVideos.includes(info.filename)) return null;
198
+
199
+ const isEnlarged = enlargedVideo === info.filename;
200
+ const isFirstVisible = idx === firstVisibleIdx;
201
+
202
+ return (
203
+ <div
204
+ key={info.filename}
205
+ className={`${
206
+ isEnlarged
207
+ ? "z-40 fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center"
208
+ : "max-w-96"
209
+ }`}
210
+ >
211
+ <p className="truncate w-full rounded-t-xl bg-gray-800 px-2 text-sm text-gray-300 flex items-center justify-between">
212
+ <span>{info.filename}</span>
213
+ <span className="flex gap-1">
214
+ <button
215
+ title={isEnlarged ? "Minimize" : "Enlarge"}
216
+ className="ml-2 p-1 hover:bg-slate-700 rounded"
217
+ onClick={() => setEnlargedVideo(isEnlarged ? null : info.filename)}
218
+ >
219
+ {isEnlarged ? <FaCompress /> : <FaExpand />}
220
+ </button>
221
+ <button
222
+ title="Hide Video"
223
+ className="ml-1 p-1 hover:bg-slate-700 rounded"
224
+ onClick={() => setHiddenVideos(prev => [...prev, info.filename])}
225
+ disabled={videosInfo.filter(v => !hiddenVideos.includes(v.filename)).length === 1}
226
+ >
227
+ <FaTimes />
228
+ </button>
229
+ </span>
230
+ </p>
231
+ <video
232
+ ref={el => videoRefs.current[idx] = el}
233
+ className={`w-full object-contain ${
234
+ isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
235
+ }`}
236
+ controls
237
+ muted
238
+ loop
239
+ preload="auto"
240
+ onPlay={(e) => handlePlay(e.currentTarget, info)}
241
+ onTimeUpdate={isFirstVisible ? handleTimeUpdate : undefined}
242
+ >
243
+ <source src={info.url} type="video/mp4" />
244
+ Your browser does not support the video tag.
245
+ </video>
246
+ </div>
247
+ );
248
+ })}
249
+ </div>
250
+ </>
251
+ );
252
+ };
253
+
254
+ export default SimpleVideosPlayer;