@flamingo-stack/openframe-frontend-core 0.0.178 → 0.0.179-snapshot.20260514181702

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/{chunk-AAX27BCR.js → chunk-DV2GT7RI.js} +3703 -4168
  2. package/dist/chunk-DV2GT7RI.js.map +1 -0
  3. package/dist/{chunk-L4T24AN4.cjs → chunk-JFGORTXV.cjs} +868 -1333
  4. package/dist/chunk-JFGORTXV.cjs.map +1 -0
  5. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  6. package/dist/components/features/entity-video-section.d.ts +54 -0
  7. package/dist/components/features/entity-video-section.d.ts.map +1 -0
  8. package/dist/components/features/index.cjs +18 -2
  9. package/dist/components/features/index.cjs.map +1 -1
  10. package/dist/components/features/index.d.ts +4 -2
  11. package/dist/components/features/index.d.ts.map +1 -1
  12. package/dist/components/features/index.js +21 -5
  13. package/dist/components/features/video-bites-display.d.ts +38 -0
  14. package/dist/components/features/video-bites-display.d.ts.map +1 -0
  15. package/dist/components/features/video-ratio-tabs.d.ts +62 -0
  16. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
  17. package/dist/components/features/video.d.ts +94 -0
  18. package/dist/components/features/video.d.ts.map +1 -0
  19. package/dist/components/index.cjs +18 -2
  20. package/dist/components/index.cjs.map +1 -1
  21. package/dist/components/index.js +21 -5
  22. package/dist/components/media-carousel.d.ts.map +1 -1
  23. package/dist/components/navigation/index.cjs +2 -2
  24. package/dist/components/navigation/index.js +1 -1
  25. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  26. package/dist/components/ui/index.cjs +2 -2
  27. package/dist/components/ui/index.js +1 -1
  28. package/dist/index.cjs +18 -2
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.js +21 -5
  31. package/package.json +2 -2
  32. package/src/components/chat/chat-message-list.tsx +62 -18
  33. package/src/components/features/entity-video-section.tsx +175 -0
  34. package/src/components/features/index.ts +9 -2
  35. package/src/components/features/video-bites-display.tsx +216 -0
  36. package/src/components/features/video-ratio-tabs.tsx +174 -0
  37. package/src/components/features/video.tsx +474 -0
  38. package/src/components/media-carousel.tsx +43 -236
  39. package/src/components/shared/product-release/release-detail-page.tsx +26 -19
  40. package/dist/chunk-AAX27BCR.js.map +0 -1
  41. package/dist/chunk-L4T24AN4.cjs.map +0 -1
  42. package/dist/components/features/video-player.d.ts +0 -44
  43. package/dist/components/features/video-player.d.ts.map +0 -1
  44. package/dist/components/features/youtube-embed.d.ts +0 -31
  45. package/dist/components/features/youtube-embed.d.ts.map +0 -1
  46. package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
  47. package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
  48. package/dist/utils/lite-youtube-embed.d.ts +0 -9
  49. package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
  50. package/src/components/features/.video-player.md +0 -44
  51. package/src/components/features/.youtube-embed.md +0 -40
  52. package/src/components/features/video-player.tsx +0 -893
  53. package/src/components/features/youtube-embed.tsx +0 -158
  54. package/src/utils/lite-youtube-embed-stub.tsx +0 -21
  55. package/src/utils/lite-youtube-embed.tsx +0 -46
@@ -1,893 +0,0 @@
1
- "use client";
2
-
3
- import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
4
- import ReactPlayer from 'react-player';
5
- import { useImageEdgeColor } from '../../hooks/ui/use-image-edge-color';
6
- import {
7
- PlayIcon,
8
- PauseIcon,
9
- Expand01Icon,
10
- Collapse01Icon,
11
- } from '../icons-v2-generated/media-playback';
12
- import {
13
- VolumeUpIcon,
14
- VolumeDownIcon,
15
- VolumeOffIcon,
16
- } from '../icons-v2-generated/audio-and-visual';
17
- import { AlertCircleIcon } from '../icons-v2-generated/interface';
18
- import { Button } from '../ui/button';
19
- import { Input } from '../ui/input';
20
-
21
- /**
22
- * Global CSS injection for iOS Safari native fullscreen captions.
23
- * Safari hides ::-webkit-media-text-track-container in fullscreen by default.
24
- * This forces it visible. Injected once, persists for the page lifetime.
25
- */
26
- let webkitCaptionCSSInjected = false;
27
- function ensureWebkitCaptionCSS() {
28
- if (webkitCaptionCSSInjected || typeof document === 'undefined') return;
29
- const style = document.createElement('style');
30
- style.textContent = `
31
- video::-webkit-media-text-track-container {
32
- display: block !important;
33
- visibility: visible !important;
34
- overflow: visible !important;
35
- }
36
- video::-webkit-media-text-track-display {
37
- white-space: pre-line;
38
- }
39
- `;
40
- document.head.appendChild(style);
41
- webkitCaptionCSSInjected = true;
42
- }
43
-
44
- // =============================================================================
45
- // SRT Subtitle Overlay System
46
- // =============================================================================
47
- // react-player's config.file.tracks is broken (GitHub #1623, #1162, #329).
48
- // The industry standard is a custom subtitle overlay synced via onProgress.
49
- // On iOS native fullscreen, we inject a <track> element so the native player
50
- // shows captions (our overlay is not visible in webkitEnterFullscreen).
51
-
52
- interface SrtCue {
53
- from: number; // milliseconds
54
- to: number; // milliseconds
55
- text: string;
56
- }
57
-
58
- /**
59
- * Parse SRT content into timestamped cues.
60
- * SRT format: sequential blocks of [index]\n[start --> end]\n[text]\n\n
61
- */
62
- function parseSrt(srt: string): SrtCue[] {
63
- const cues: SrtCue[] = [];
64
- const blocks = srt.replace(/\r\n/g, '\n').trim().split(/\n\n+/);
65
-
66
- for (const block of blocks) {
67
- const lines = block.split('\n');
68
- // Find the timestamp line (contains " --> ")
69
- const tsIndex = lines.findIndex(l => l.includes(' --> '));
70
- if (tsIndex === -1) continue;
71
-
72
- const [startStr, endStr] = lines[tsIndex].split(' --> ');
73
- const from = parseSrtTimestamp(startStr?.trim());
74
- const to = parseSrtTimestamp(endStr?.trim());
75
- if (from === null || to === null) continue;
76
-
77
- // Everything after the timestamp line is the subtitle text
78
- const text = lines.slice(tsIndex + 1).join('\n').trim();
79
- if (text) cues.push({ from, to, text });
80
- }
81
-
82
- return cues;
83
- }
84
-
85
- /** Parse "HH:MM:SS,mmm" to milliseconds */
86
- function parseSrtTimestamp(ts: string | undefined): number | null {
87
- if (!ts) return null;
88
- const match = ts.match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/);
89
- if (!match) return null;
90
- return (
91
- parseInt(match[1]) * 3600000 +
92
- parseInt(match[2]) * 60000 +
93
- parseInt(match[3]) * 1000 +
94
- parseInt(match[4])
95
- );
96
- }
97
-
98
- /**
99
- * Hook: parse SRT and provide the active subtitle text for the current time.
100
- * Uses linear scan — performant for typical SRT files (< 500 cues).
101
- */
102
- function useSubtitleOverlay(srtContent: string | undefined) {
103
- const cues = useMemo(() => srtContent ? parseSrt(srtContent) : [], [srtContent]);
104
- const [activeText, setActiveText] = useState<string | null>(null);
105
-
106
- const updateTime = useCallback((playedSeconds: number) => {
107
- const timeMs = playedSeconds * 1000;
108
- // Linear scan is fine for typical SRT files (<500 cues)
109
- const active = cues.find(c => timeMs >= c.from && timeMs <= c.to);
110
- setActiveText(active?.text ?? null);
111
- }, [cues]);
112
-
113
- return { activeText, updateTime, hasCues: cues.length > 0 };
114
- }
115
-
116
- interface VideoPlayerProps {
117
- url: string;
118
- title?: string;
119
- poster?: string;
120
- className?: string;
121
- showTitle?: boolean;
122
- autoPlay?: boolean;
123
- loop?: boolean;
124
- muted?: boolean;
125
- useNativeAspectRatio?: boolean;
126
- /** SRT subtitle content string. Parsed and rendered as overlay synced via onProgress. */
127
- srtContent?: string;
128
- /** HTTPS URL to a VTT/SRT captions file. Required for iOS native fullscreen subtitles
129
- * (iOS Safari does not support blob: URLs for <track> elements). */
130
- captionsUrl?: string;
131
- /** Label for the subtitle track (default: 'English') */
132
- subtitleLabel?: string;
133
- /**
134
- * Controls how aggressively the browser preloads the video before the user
135
- * presses play. Mirrors the underlying `<video preload>` attribute.
136
- *
137
- * - `'auto'` — browser may download the entire file. Use for hero
138
- * videos that you expect every visitor to play.
139
- * - `'metadata'` — (default) browser fetches the moov atom + a few KB
140
- * so dimensions, duration, and the first frame are
141
- * ready by the time the user clicks. Tiny bandwidth
142
- * cost vs. dramatic click→first-frame improvement.
143
- * - `'none'` — zero bytes fetched until click. Opt in for modal
144
- * videos that are rarely played.
145
- *
146
- * Caveat for long videos with `moov` at the end (common for iPhone
147
- * screen recordings): `'metadata'` may pull several MB before any frame
148
- * decodes. Run a faststart remux on the source to eliminate this. For
149
- * grid/list pages, gate mount with `useNearViewport` (lib hook) so only
150
- * near-viewport bites mount + start their metadata fetch.
151
- *
152
- * @default 'metadata'
153
- */
154
- preloadStrategy?: 'auto' | 'metadata' | 'none';
155
- }
156
-
157
- /** Playback speed options */
158
- const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
159
-
160
- /** Format seconds to M:SS or H:MM:SS display */
161
- function formatTime(secs: number): string {
162
- if (!secs || !isFinite(secs)) return '0:00';
163
- const h = Math.floor(secs / 3600);
164
- const m = Math.floor((secs % 3600) / 60);
165
- const s = Math.floor(secs % 60);
166
- if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
167
- return `${m}:${s.toString().padStart(2, '0')}`;
168
- }
169
-
170
- export const VideoPlayer: React.FC<VideoPlayerProps> = ({
171
- url,
172
- title,
173
- poster,
174
- className = "",
175
- showTitle = false,
176
- autoPlay = false,
177
- loop = false,
178
- muted = false,
179
- useNativeAspectRatio = false,
180
- srtContent,
181
- captionsUrl,
182
- subtitleLabel,
183
- preloadStrategy = 'metadata',
184
- }) => {
185
- // =========================================================================
186
- // Core state
187
- // =========================================================================
188
- const [hasError, setHasError] = useState(false);
189
- const [isPlaying, setIsPlaying] = useState(autoPlay);
190
- const [mounted, setMounted] = useState(false);
191
- const [hasStarted, setHasStarted] = useState(autoPlay);
192
- const playerRef = useRef<ReactPlayer | null>(null);
193
- const containerRef = useRef<HTMLDivElement | null>(null);
194
-
195
- // Custom controls state
196
- const [played, setPlayed] = useState(0);
197
- const [loaded, setLoaded] = useState(0);
198
- const [duration, setDuration] = useState(0);
199
- const [volume, setVolume] = useState(0.8);
200
- const [prevVolume, setPrevVolume] = useState(0.8);
201
- const [isMuted, setIsMuted] = useState(muted);
202
- const [isBuffering, setIsBuffering] = useState(false);
203
- const [showControls, setShowControls] = useState(true);
204
- const hideTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
205
- const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
206
- const iosFullscreenTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
207
-
208
- // Subtitle + fullscreen state
209
- const [captionsEnabled, setCaptionsEnabled] = useState(true);
210
- const [isFullscreen, setIsFullscreen] = useState(false);
211
- const { activeText, updateTime, hasCues } = useSubtitleOverlay(srtContent);
212
-
213
- // =========================================================================
214
- // Fullscreen — industry standard dual-mode (Plyr / Video.js / Vidstack pattern)
215
- //
216
- // Desktop/Android: container.requestFullscreen() — custom controls survive
217
- // iOS Safari: video.webkitEnterFullscreen() — native iOS controls take over
218
- //
219
- // Every battle-tested player (Plyr, Video.js, Vidstack, YouTube mobile web)
220
- // surrenders custom controls to iOS in fullscreen. The Fullscreen API doesn't
221
- // support divs on iOS, and CSS simulation has too many edge cases (notch,
222
- // address bar, orientation). Native iOS fullscreen is the correct UX.
223
- // =========================================================================
224
- useEffect(() => {
225
- const onChange = () => {
226
- const fsEl = document.fullscreenElement || (document as any).webkitFullscreenElement;
227
- setIsFullscreen(!!fsEl);
228
- };
229
- document.addEventListener('fullscreenchange', onChange);
230
- document.addEventListener('webkitfullscreenchange', onChange);
231
- return () => {
232
- document.removeEventListener('fullscreenchange', onChange);
233
- document.removeEventListener('webkitfullscreenchange', onChange);
234
- };
235
- }, []);
236
-
237
- /** Activate all caption/subtitle tracks on a video element */
238
- const activateCaptionTracks = useCallback((video: HTMLVideoElement) => {
239
- for (let i = 0; i < video.textTracks.length; i++) {
240
- if (video.textTracks[i].kind === 'captions' || video.textTracks[i].kind === 'subtitles') {
241
- video.textTracks[i].mode = 'showing';
242
- }
243
- }
244
- }, []);
245
-
246
- /**
247
- * iOS native fullscreen with caption support.
248
- * Steps (validated against Plyr, Video.js, Mux, Vidstack source):
249
- * 1. Inject CSS to force ::-webkit-media-text-track-container visible
250
- * 2. Create <track> element with real HTTPS captionsUrl
251
- * 3. Append to <video> with default attribute
252
- * 4. Set textTrack.mode = 'showing'
253
- * 5. Wait for track to load THEN call webkitEnterFullscreen
254
- */
255
- const enterNativeVideoFullscreen = useCallback(() => {
256
- const video = playerRef.current?.getInternalPlayer() as HTMLVideoElement | null;
257
- if (!video || !(video as any).webkitEnterFullscreen) return;
258
-
259
- ensureWebkitCaptionCSS();
260
-
261
- // Determine the captions source URL for native iOS fullscreen.
262
- // iOS Safari does NOT support blob: URLs for <track> elements (WebKit TextTrackLoader limitation).
263
- // A real HTTPS URL is required. captionsUrl is served by /api/captions/[entityType]/[entityId].
264
- const trackSrc = captionsUrl || null;
265
-
266
- if (!trackSrc) {
267
- // No captions URL available — enter fullscreen without subtitles
268
- try { (video as any).webkitEnterFullscreen(); } catch { /* requires user gesture */ }
269
- return;
270
- }
271
-
272
- // Clean up previous track + timer
273
- const old = video.querySelector('track[data-native-cc]');
274
- if (old) old.remove();
275
- clearTimeout(iosFullscreenTimerRef.current);
276
-
277
- // Create <track> element with real HTTPS URL
278
- const track = document.createElement('track');
279
- track.kind = 'captions';
280
- track.label = subtitleLabel || 'English';
281
- track.srclang = 'en';
282
- track.default = true;
283
- track.setAttribute('data-native-cc', 'true');
284
-
285
- video.appendChild(track);
286
- track.src = trackSrc;
287
-
288
- // Wait for track to load, then enter fullscreen
289
- let entered = false;
290
- const doFullscreen = () => {
291
- if (entered) return;
292
- entered = true;
293
- activateCaptionTracks(video);
294
- try { (video as any).webkitEnterFullscreen(); } catch { /* requires user gesture */ }
295
- };
296
-
297
- track.addEventListener('load', doFullscreen, { once: true });
298
- iosFullscreenTimerRef.current = setTimeout(() => {
299
- track.removeEventListener('load', doFullscreen);
300
- doFullscreen();
301
- }, 500);
302
- }, [captionsUrl, subtitleLabel, activateCaptionTracks]);
303
-
304
- const toggleFullscreen = useCallback(() => {
305
- const container = containerRef.current;
306
- if (!container) return;
307
-
308
- if (isFullscreen) {
309
- try {
310
- if (document.exitFullscreen) document.exitFullscreen();
311
- else if ((document as any).webkitExitFullscreen) (document as any).webkitExitFullscreen();
312
- } catch { /* exit may fail if not in fullscreen */ }
313
- return;
314
- }
315
-
316
- // Enter — try container first (desktop/Android), then native video (iOS)
317
- if (container.requestFullscreen) {
318
- container.requestFullscreen().catch(() => enterNativeVideoFullscreen());
319
- } else if ((container as any).webkitRequestFullscreen) {
320
- (container as any).webkitRequestFullscreen();
321
- } else {
322
- enterNativeVideoFullscreen();
323
- }
324
- }, [isFullscreen, enterNativeVideoFullscreen]);
325
-
326
- // =========================================================================
327
- // Volume
328
- // =========================================================================
329
- const toggleMute = useCallback(() => {
330
- if (isMuted) {
331
- setIsMuted(false);
332
- setVolume(prevVolume || 0.5);
333
- } else {
334
- setPrevVolume(volume);
335
- setIsMuted(true);
336
- setVolume(0);
337
- }
338
- }, [isMuted, volume, prevVolume]);
339
-
340
- const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
341
- const val = parseFloat(e.target.value);
342
- setVolume(val);
343
- setIsMuted(val === 0);
344
- if (val > 0) setPrevVolume(val);
345
- }, []);
346
-
347
- // =========================================================================
348
- // Auto-hide controls (3s inactivity)
349
- // Desktop: mouse move shows controls, hide after 3s
350
- // Mobile: tap toggles controls visibility, auto-hide after 3s when shown
351
- // =========================================================================
352
- const startHideTimer = useCallback(() => {
353
- clearTimeout(hideTimeoutRef.current);
354
- if (isPlaying) {
355
- hideTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
356
- }
357
- }, [isPlaying]);
358
-
359
- // Desktop: mouse movement
360
- const handleMouseMove = useCallback(() => {
361
- setShowControls(true);
362
- startHideTimer();
363
- }, [startHideTimer]);
364
-
365
- // Mobile: tap on video area toggles controls (not on controls bar itself)
366
- const handleTouchToggle = useCallback(() => {
367
- if (!hasStarted) return;
368
- setShowControls(prev => {
369
- const next = !prev;
370
- clearTimeout(hideTimeoutRef.current);
371
- if (next && isPlaying) {
372
- hideTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
373
- }
374
- return next;
375
- });
376
- }, [hasStarted, isPlaying]);
377
-
378
- // =========================================================================
379
- // Keyboard shortcuts (Space, Arrow keys, M, F)
380
- // =========================================================================
381
- useEffect(() => {
382
- if (!hasStarted) return;
383
- const el = containerRef.current;
384
- if (!el) return;
385
-
386
- const onKey = (e: KeyboardEvent) => {
387
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
388
- switch (e.key) {
389
- case ' ':
390
- case 'k':
391
- e.preventDefault();
392
- setIsPlaying(prev => !prev);
393
- break;
394
- case 'ArrowLeft':
395
- e.preventDefault();
396
- playerRef.current?.seekTo(Math.max(0, (playerRef.current?.getCurrentTime() ?? 0) - 5), 'seconds');
397
- break;
398
- case 'ArrowRight':
399
- e.preventDefault();
400
- playerRef.current?.seekTo(Math.min(duration, (playerRef.current?.getCurrentTime() ?? 0) + 5), 'seconds');
401
- break;
402
- case 'ArrowUp':
403
- e.preventDefault();
404
- setVolume(v => { const nv = Math.min(1, v + 0.1); setIsMuted(false); return nv; });
405
- break;
406
- case 'ArrowDown':
407
- e.preventDefault();
408
- setVolume(v => { const nv = Math.max(0, v - 0.1); if (nv === 0) setIsMuted(true); return nv; });
409
- break;
410
- case 'm': case 'M':
411
- e.preventDefault();
412
- toggleMute();
413
- break;
414
- case 'f': case 'F':
415
- e.preventDefault();
416
- toggleFullscreen();
417
- break;
418
- case 'c': case 'C':
419
- e.preventDefault();
420
- setCaptionsEnabled(prev => !prev);
421
- break;
422
- }
423
- };
424
-
425
- el.addEventListener('keydown', onKey);
426
- return () => el.removeEventListener('keydown', onKey);
427
- }, [hasStarted, duration, toggleMute, toggleFullscreen]);
428
-
429
- // =========================================================================
430
- // Helpers
431
- // =========================================================================
432
-
433
- // Seek preview tooltip
434
- const [seekPreview, setSeekPreview] = useState<{ fraction: number; x: number } | null>(null);
435
-
436
- // Playback speed
437
- const [playbackRate, setPlaybackRate] = useState(1);
438
- const cycleSpeed = useCallback(() => {
439
- setPlaybackRate(prev => {
440
- const idx = SPEED_OPTIONS.indexOf(prev as typeof SPEED_OPTIONS[number]);
441
- return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
442
- });
443
- }, []);
444
-
445
- // =========================================================================
446
- // Progress bar: click-to-seek, drag-to-scrub (desktop), touch-to-scrub (mobile)
447
- // YouTube pattern: mouseDown starts tracking, mouseMove on document updates,
448
- // mouseUp stops tracking. This allows dragging outside the bar.
449
- // =========================================================================
450
- const progressBarRef = useRef<HTMLDivElement | null>(null);
451
- const isDraggingRef = useRef(false);
452
- const dragListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
453
-
454
- const seekToClientX = useCallback((clientX: number) => {
455
- const rect = progressBarRef.current?.getBoundingClientRect();
456
- if (!rect) return;
457
- const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
458
- setPlayed(fraction);
459
- playerRef.current?.seekTo(fraction, 'fraction');
460
- }, []);
461
-
462
- // Desktop: mouseDown on bar starts drag, mouseMove/mouseUp on document
463
- const handleProgressMouseDown = useCallback((e: React.MouseEvent) => {
464
- e.stopPropagation();
465
- e.preventDefault();
466
- isDraggingRef.current = true;
467
- seekToClientX(e.clientX);
468
-
469
- // Clean up any existing listeners first
470
- if (dragListenersRef.current) {
471
- document.removeEventListener('mousemove', dragListenersRef.current.move);
472
- document.removeEventListener('mouseup', dragListenersRef.current.up);
473
- }
474
-
475
- const onMouseMove = (ev: MouseEvent) => {
476
- if (!isDraggingRef.current) return;
477
- seekToClientX(ev.clientX);
478
- };
479
- const onMouseUp = () => {
480
- isDraggingRef.current = false;
481
- document.removeEventListener('mousemove', onMouseMove);
482
- document.removeEventListener('mouseup', onMouseUp);
483
- dragListenersRef.current = null;
484
- };
485
- dragListenersRef.current = { move: onMouseMove, up: onMouseUp };
486
- document.addEventListener('mousemove', onMouseMove);
487
- document.addEventListener('mouseup', onMouseUp);
488
- }, [seekToClientX]);
489
-
490
- // Mobile: touchStart + touchMove on the bar
491
- const handleProgressTouchStart = useCallback((e: React.TouchEvent) => {
492
- e.stopPropagation();
493
- const touch = e.touches[0];
494
- if (touch) seekToClientX(touch.clientX);
495
- }, [seekToClientX]);
496
-
497
- const handleProgressTouchMove = useCallback((e: React.TouchEvent) => {
498
- e.stopPropagation();
499
- const touch = e.touches[0];
500
- if (touch) seekToClientX(touch.clientX);
501
- }, [seekToClientX]);
502
-
503
- const handleProgressKeyDown = useCallback((e: React.KeyboardEvent) => {
504
- if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(Math.min(duration, (playerRef.current?.getCurrentTime() ?? 0) + 5), 'seconds'); }
505
- if (e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(Math.max(0, (playerRef.current?.getCurrentTime() ?? 0) - 5), 'seconds'); }
506
- if (e.key === 'Home') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(0, 'seconds'); }
507
- if (e.key === 'End') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(duration, 'seconds'); }
508
- }, [duration]);
509
-
510
- const handleProgressHover = useCallback((e: React.MouseEvent) => {
511
- if (isDraggingRef.current) return; // Don't update tooltip while dragging
512
- const rect = e.currentTarget.getBoundingClientRect();
513
- const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
514
- const x = Math.max(30, Math.min(rect.width - 30, e.clientX - rect.left));
515
- setSeekPreview({ fraction, x });
516
- }, []);
517
-
518
- // Desktop: Double-click = fullscreen, single click = play/pause
519
- // Mobile: Single tap = toggle controls (handled by onTouchEnd)
520
- const isTouchRef = useRef(false);
521
-
522
- const handleContainerClick = useCallback((e: React.MouseEvent) => {
523
- // Skip click events that originated from touch (mobile)
524
- if (isTouchRef.current) { isTouchRef.current = false; return; }
525
- if ((e.target as HTMLElement).closest('.video-controls-bar')) return;
526
- if (!hasStarted) return;
527
- if (clickTimerRef.current) {
528
- clearTimeout(clickTimerRef.current);
529
- clickTimerRef.current = undefined;
530
- toggleFullscreen();
531
- } else {
532
- clickTimerRef.current = setTimeout(() => {
533
- clickTimerRef.current = undefined;
534
- setIsPlaying(prev => !prev);
535
- }, 250);
536
- }
537
- }, [hasStarted, toggleFullscreen]);
538
-
539
- const handleContainerTouchEnd = useCallback((e: React.TouchEvent) => {
540
- if ((e.target as HTMLElement).closest('.video-controls-bar')) return;
541
- isTouchRef.current = true; // Suppress the subsequent click event
542
- if (!hasStarted) return;
543
- handleTouchToggle();
544
- }, [hasStarted, handleTouchToggle]);
545
-
546
- // Posters come from the server (admin form thumbnail extraction or Mux
547
- // poster URL post-Phase-2). The legacy hidden-`<video>` first-frame
548
- // extractor was deleted — it competed with the main player for sockets
549
- // on multi-bite pages and produced flaky CORS failures.
550
- const effectivePoster = poster || undefined;
551
- const posterBgColor = useImageEdgeColor(effectivePoster);
552
-
553
- useEffect(() => {
554
- setMounted(true);
555
- return () => {
556
- // Clean up all pending timers, drag listeners, and state on unmount
557
- clearTimeout(clickTimerRef.current);
558
- clearTimeout(hideTimeoutRef.current);
559
- clearTimeout(iosFullscreenTimerRef.current);
560
- isDraggingRef.current = false;
561
- if (dragListenersRef.current) {
562
- document.removeEventListener('mousemove', dragListenersRef.current.move);
563
- document.removeEventListener('mouseup', dragListenersRef.current.up);
564
- dragListenersRef.current = null;
565
- }
566
- };
567
- }, []);
568
-
569
- // Re-activate caption tracks when iOS enters native fullscreen
570
- // (belt-and-suspenders — ensures tracks stay visible)
571
- useEffect(() => {
572
- if (!hasStarted) return;
573
- const video = playerRef.current?.getInternalPlayer() as HTMLVideoElement | null;
574
- if (!video) return;
575
-
576
- const onBeginFS = () => activateCaptionTracks(video);
577
-
578
- video.addEventListener('webkitbeginfullscreen', onBeginFS);
579
- return () => video.removeEventListener('webkitbeginfullscreen', onBeginFS);
580
- }, [hasStarted, activateCaptionTracks]);
581
-
582
- const handleError = useCallback(() => setHasError(true), []);
583
- const handlePlay = useCallback(() => { setIsPlaying(true); setHasStarted(true); }, []);
584
- const handlePause = useCallback(() => setIsPlaying(false), []);
585
- const handleEnded = useCallback(() => setIsPlaying(false), []);
586
- const handlePlayClick = useCallback(() => {
587
- // iOS user-activation belt-and-suspenders: react-player's `playing={isPlaying}`
588
- // state-driven .play() runs in a state-update microtask, which Safari iOS
589
- // treats as non-user-initiated. Calling .play() synchronously inside the
590
- // click handler preserves the user-activation flag across all preload
591
- // strategies. If the play() promise rejects (codec, autoplay policy), the
592
- // underlying <video>'s `error` event fires and `handleError` surfaces the
593
- // error UI — no separate grace timer needed.
594
- //
595
- // Guard: `getInternalPlayer()` returns YouTube/Vimeo iframe wrappers when
596
- // those URL types are passed; we only act on the native <video> element.
597
- const native = playerRef.current?.getInternalPlayer();
598
- if (native instanceof HTMLVideoElement) {
599
- native.play().catch(() => { /* error UI handled by ReactPlayer's onError */ });
600
- } else if (process.env.NODE_ENV !== 'production') {
601
- console.warn('[VideoPlayer] sync play(): no native HTMLVideoElement yet');
602
- }
603
- setHasStarted(true);
604
- setIsPlaying(true);
605
- }, []);
606
-
607
- const handleProgress = useCallback(({ played: p, loaded: l, playedSeconds }: { played: number; loaded: number; playedSeconds: number }) => {
608
- setPlayed(p);
609
- setLoaded(l);
610
- updateTime(playedSeconds);
611
- }, [updateTime]);
612
-
613
- const handleBuffer = useCallback(() => setIsBuffering(true), []);
614
- const handleBufferEnd = useCallback(() => setIsBuffering(false), []);
615
-
616
- // SSR placeholder — consistent loading skeleton
617
- if (!mounted) {
618
- return (
619
- <div className={`video-player-container ${className}`}>
620
- <div
621
- className="video-wrapper relative w-full"
622
- style={useNativeAspectRatio ? {} : { paddingBottom: '56.25%' }}
623
- >
624
- <div className={useNativeAspectRatio
625
- ? "bg-black rounded-md flex items-center justify-center min-h-[200px]"
626
- : "absolute inset-0 bg-black rounded-md flex items-center justify-center"
627
- }>
628
- <div className="w-16 h-16 rounded-full bg-ods-accent flex items-center justify-center shadow-lg">
629
- <PlayIcon size={24} className="ml-1 text-ods-text-on-accent" />
630
- </div>
631
- </div>
632
- </div>
633
- </div>
634
- );
635
- }
636
-
637
- if (hasError) {
638
- return (
639
- <div className={`video-player-error ${className}`}>
640
- <div className="error-state bg-ods-card border border-ods-border rounded-md p-6 text-center">
641
- <div className="error-icon flex justify-center mb-4">
642
- <AlertCircleIcon size={48} className="text-ods-attention-red-error" />
643
- </div>
644
- <div className="error-title font-sans font-semibold text-lg text-ods-attention-red-error mb-2">
645
- Video Unavailable
646
- </div>
647
- <div className="error-description font-sans text-sm text-ods-text-secondary mb-4">
648
- Unable to load video. The video may be unavailable or the format is not supported.
649
- </div>
650
- </div>
651
- </div>
652
- );
653
- }
654
-
655
- return (
656
- <div className={`video-player-container ${className}`}>
657
- {title && showTitle && (
658
- <div className="video-title font-sans text-lg font-medium text-ods-text-primary mb-3">
659
- {title}
660
- </div>
661
- )}
662
-
663
- {/* Container — fullscreened via Fullscreen API (desktop/Android) so custom controls + subtitles travel with video.
664
- On iOS Safari, native video.webkitEnterFullscreen() is used (Apple's controls take over — industry standard). */}
665
- <div
666
- ref={containerRef}
667
- tabIndex={0}
668
- role="region"
669
- aria-label={title || 'Video player'}
670
- className={`video-wrapper relative w-full outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${
671
- isFullscreen ? 'bg-black' : ''
672
- } ${isFullscreen && !showControls && isPlaying ? 'cursor-none' : ''}`}
673
- style={
674
- isFullscreen ? { width: '100%', height: '100%' }
675
- : useNativeAspectRatio ? {}
676
- : { paddingBottom: '56.25%' }
677
- }
678
- onMouseMove={handleMouseMove}
679
- onMouseLeave={startHideTimer}
680
- onTouchEnd={handleContainerTouchEnd}
681
- onClick={handleContainerClick}
682
- >
683
- {/* Initial play overlay */}
684
- {!hasStarted && !hasError && (
685
- <div className="absolute inset-0 cursor-pointer group z-20" onClick={handlePlayClick}>
686
- {effectivePoster && (
687
- <img src={effectivePoster} alt={title || 'Video thumbnail'}
688
- className="w-full h-full object-contain rounded-md"
689
- style={{ backgroundColor: posterBgColor }} />
690
- )}
691
- <div className={`absolute inset-0 ${effectivePoster ? 'bg-black/40' : 'bg-black/20'} group-hover:bg-black/50 transition-all flex items-center justify-center rounded-md`}>
692
- <div className="w-16 h-16 rounded-full bg-ods-accent hover:bg-ods-accent/90 transition-all flex items-center justify-center shadow-lg">
693
- <PlayIcon size={24} className="ml-1 text-ods-text-on-accent" />
694
- </div>
695
- </div>
696
- </div>
697
- )}
698
-
699
- {/* ReactPlayer */}
700
- <div className={
701
- isFullscreen ? "video-player absolute inset-0"
702
- : useNativeAspectRatio ? "video-player rounded-md overflow-hidden border border-ods-border bg-ods-background"
703
- : "video-player absolute inset-0 rounded-md overflow-hidden border border-ods-border bg-ods-background"
704
- }>
705
- <ReactPlayer
706
- ref={playerRef}
707
- url={url}
708
- width="100%"
709
- height="100%"
710
- controls={false}
711
- playing={isPlaying}
712
- playbackRate={playbackRate}
713
- loop={loop}
714
- muted={isMuted}
715
- volume={isMuted ? 0 : volume}
716
- onError={handleError}
717
- onPlay={handlePlay}
718
- onPause={handlePause}
719
- onEnded={handleEnded}
720
- onDuration={setDuration}
721
- onBuffer={handleBuffer}
722
- onBufferEnd={handleBufferEnd}
723
- onProgress={handleProgress}
724
- progressInterval={200}
725
- config={{ file: { attributes: { controlsList: 'nodownload', playsInline: true, preload: hasStarted ? 'auto' : preloadStrategy } } }}
726
- light={false}
727
- playsinline
728
- />
729
- </div>
730
-
731
- {/* Buffering spinner */}
732
- {isBuffering && hasStarted && (
733
- <div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
734
- <div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
735
- </div>
736
- )}
737
-
738
- {/* Subtitle overlay — moves up/down in sync with controls bar visibility */}
739
- {captionsEnabled && activeText && hasStarted && (
740
- <div
741
- className="absolute left-[5%] right-[5%] text-center pointer-events-none z-10 transition-[bottom] duration-300 ease-in-out"
742
- style={{ bottom: (showControls || !isPlaying) ? 52 : 12 }}
743
- >
744
- <span
745
- className="inline-block bg-black/80 text-white leading-relaxed px-4 py-1.5 rounded font-sans font-medium whitespace-pre-line"
746
- style={{
747
- fontSize: isFullscreen ? 'clamp(20px, 3.3vh, 42px)' : 'clamp(15px, 3.3cqw, 26px)',
748
- maxWidth: '90%',
749
- textShadow: '0 1px 4px rgba(0,0,0,0.6)',
750
- WebkitTextStroke: '0.3px rgba(0,0,0,0.3)',
751
- }}
752
- >
753
- {activeText}
754
- </span>
755
- </div>
756
- )}
757
-
758
- {/* Custom Controls Bar — always on, CC button shown only when subtitles exist */}
759
- {hasStarted && (
760
- <div
761
- className={`video-controls-bar absolute bottom-0 left-0 right-0 z-30 transition-opacity duration-300 ${
762
- showControls || !isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
763
- }`}
764
- onTouchEnd={(e) => { e.stopPropagation(); startHideTimer(); }}
765
- >
766
- <div className="bg-gradient-to-t from-black/90 via-black/40 to-transparent pt-6 pb-1.5 px-2.5 rounded-b-md">
767
- {/* Progress bar — YouTube standard: 4px track, 24px+ touch hit area */}
768
- <div
769
- className="group/seek relative w-full h-6 cursor-pointer mb-0.5 flex items-center"
770
- ref={progressBarRef}
771
- role="slider"
772
- aria-label="Video progress"
773
- aria-valuenow={Math.round(played * 100)}
774
- aria-valuetext={`${formatTime(played * duration)} of ${formatTime(duration)}`}
775
- aria-valuemin={0}
776
- aria-valuemax={100}
777
- tabIndex={0}
778
- onMouseDown={handleProgressMouseDown}
779
- onTouchStart={handleProgressTouchStart}
780
- onTouchMove={handleProgressTouchMove}
781
- onTouchEnd={(e) => e.stopPropagation()}
782
- onMouseMove={handleProgressHover}
783
- onMouseLeave={() => setSeekPreview(null)}
784
- onKeyDown={handleProgressKeyDown}
785
- >
786
- {/* Seek preview tooltip */}
787
- {seekPreview && duration > 0 && (
788
- <div
789
- className="absolute -top-7 -translate-x-1/2 bg-black/90 text-white text-[11px] font-mono px-1.5 py-0.5 rounded pointer-events-none whitespace-nowrap z-10"
790
- style={{ left: seekPreview.x }}
791
- >
792
- {formatTime(seekPreview.fraction * duration)}
793
- </div>
794
- )}
795
- {/* Visual track — 4px, expands to 5px on hover (YouTube standard) */}
796
- <div className="absolute left-0 right-0 h-1 [@media(hover:hover)]:group-hover/seek:h-[5px] transition-all top-1/2 -translate-y-1/2">
797
- <div className="absolute inset-0 bg-white/20 rounded-full" />
798
- <div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-all"
799
- style={{ width: `${loaded * 100}%` }} />
800
- <div className="absolute inset-y-0 left-0 bg-white rounded-full"
801
- style={{ width: `${played * 100}%` }} />
802
- </div>
803
- {/* Scrub thumb — 12px (YouTube standard: 13px) */}
804
- <div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-sm opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/seek:opacity-100 [@media(hover:hover)]:group-hover/seek:scale-110 transition-all"
805
- style={{ left: `calc(${played * 100}% - 6px)` }} />
806
- </div>
807
-
808
- {/* Controls row — 36px height (YouTube standard) */}
809
- <div className="flex items-center justify-between h-9">
810
- {/* Left: Play, Volume, Time */}
811
- <div className="flex items-center">
812
- {/* Play/Pause — 36×36 button, 20px icon */}
813
- <Button variant="transparent" size="icon"
814
- onClick={(e) => { e.stopPropagation(); setIsPlaying(prev => !prev); }}
815
- className="h-9 w-9 text-white hover:text-white/80 hover:bg-white/10"
816
- aria-label={isPlaying ? 'Pause (Space)' : 'Play (Space)'}>
817
- {isPlaying ? <PauseIcon size={20} color="white" /> : <PlayIcon size={20} color="white" />}
818
- </Button>
819
-
820
- {/* Volume: icon + hover-reveal slider */}
821
- <div className="group/vol flex items-center">
822
- <Button variant="transparent" size="icon"
823
- onClick={(e) => { e.stopPropagation(); toggleMute(); }}
824
- className="h-9 w-9 text-white hover:text-white/80 hover:bg-white/10"
825
- aria-label={isMuted ? 'Unmute (M)' : 'Mute (M)'}>
826
- {isMuted || volume === 0
827
- ? <VolumeOffIcon size={18} color="white" />
828
- : volume < 0.5
829
- ? <VolumeDownIcon size={18} color="white" />
830
- : <VolumeUpIcon size={18} color="white" />
831
- }
832
- </Button>
833
- <div className="w-0 overflow-hidden group-hover/vol:w-16 transition-all duration-200 flex items-center">
834
- <Input
835
- type="range" min={0} max={1} step={0.01}
836
- value={isMuted ? 0 : volume}
837
- onChange={handleVolumeChange}
838
- onClick={(e) => e.stopPropagation()}
839
- aria-label="Volume"
840
- className="w-14 ml-1"
841
- style={{ background: `linear-gradient(to right, white ${(isMuted ? 0 : volume) * 100}%, rgba(255,255,255,0.3) ${(isMuted ? 0 : volume) * 100}%)` }}
842
- />
843
- </div>
844
- </div>
845
-
846
- {/* Time — 12px font (YouTube standard) */}
847
- <span className="text-white/70 text-[12px] font-mono tabular-nums select-none ml-1.5">
848
- {formatTime(played * duration)} / {formatTime(duration)}
849
- </span>
850
- </div>
851
-
852
- {/* Right: Speed, CC, Fullscreen */}
853
- <div className="flex items-center">
854
- {/* Playback speed — compact text button */}
855
- <Button variant="transparent" size="small-legacy"
856
- onClick={(e) => { e.stopPropagation(); cycleSpeed(); }}
857
- className={`h-9 px-1.5 text-[11px] font-bold rounded hover:bg-white/10 ${
858
- playbackRate !== 1 ? 'text-white' : 'text-white/70 hover:text-white'
859
- }`}
860
- title="Playback speed"
861
- aria-label={`Playback speed ${playbackRate}x`}>
862
- {playbackRate}x
863
- </Button>
864
-
865
- {hasCues && (
866
- <Button variant="transparent" size="small-legacy"
867
- onClick={(e) => { e.stopPropagation(); setCaptionsEnabled(prev => !prev); }}
868
- className={`h-9 px-1.5 text-[11px] font-bold rounded ${
869
- captionsEnabled ? 'bg-white text-black hover:bg-white/90' : 'text-white/50 hover:text-white hover:bg-white/10'
870
- }`}
871
- style={{ borderBottom: captionsEnabled ? '2px solid white' : '2px solid transparent' }}
872
- title={captionsEnabled ? 'Hide captions (C)' : 'Show captions (C)'}
873
- aria-label={captionsEnabled ? 'Hide captions' : 'Show captions'}>
874
- CC
875
- </Button>
876
- )}
877
-
878
- <Button variant="transparent" size="icon"
879
- onClick={(e) => { e.stopPropagation(); toggleFullscreen(); }}
880
- className="h-9 w-9 text-white/80 hover:text-white hover:bg-white/10"
881
- title={isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
882
- aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
883
- {isFullscreen ? <Collapse01Icon size={18} color="white" /> : <Expand01Icon size={18} color="white" />}
884
- </Button>
885
- </div>
886
- </div>
887
- </div>
888
- </div>
889
- )}
890
- </div>
891
- </div>
892
- );
893
- };