@flamingo-stack/openframe-frontend-core 0.0.177 → 0.0.178-snapshot.20260514174355

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 (69) hide show
  1. package/dist/{chunk-C6ZMI4UB.cjs → chunk-4TM2SBMX.cjs} +884 -1513
  2. package/dist/chunk-4TM2SBMX.cjs.map +1 -0
  3. package/dist/{chunk-WX7PT5C7.cjs → chunk-ALW3D72O.cjs} +61 -2
  4. package/dist/chunk-ALW3D72O.cjs.map +1 -0
  5. package/dist/{chunk-KB2N44BY.js → chunk-FMWHOUFE.js} +61 -2
  6. package/dist/chunk-FMWHOUFE.js.map +1 -0
  7. package/dist/{chunk-6LDN3CIY.js → chunk-ZMQP3UZJ.js} +3701 -4330
  8. package/dist/chunk-ZMQP3UZJ.js.map +1 -0
  9. package/dist/components/features/entity-video-section.d.ts +54 -0
  10. package/dist/components/features/entity-video-section.d.ts.map +1 -0
  11. package/dist/components/features/index.cjs +17 -3
  12. package/dist/components/features/index.cjs.map +1 -1
  13. package/dist/components/features/index.d.ts +4 -2
  14. package/dist/components/features/index.d.ts.map +1 -1
  15. package/dist/components/features/index.js +22 -8
  16. package/dist/components/features/video-bites-display.d.ts +38 -0
  17. package/dist/components/features/video-bites-display.d.ts.map +1 -0
  18. package/dist/components/features/video-ratio-tabs.d.ts +62 -0
  19. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
  20. package/dist/components/features/video.d.ts +94 -0
  21. package/dist/components/features/video.d.ts.map +1 -0
  22. package/dist/components/index.cjs +17 -3
  23. package/dist/components/index.cjs.map +1 -1
  24. package/dist/components/index.js +22 -8
  25. package/dist/components/media-carousel.d.ts.map +1 -1
  26. package/dist/components/navigation/index.cjs +3 -3
  27. package/dist/components/navigation/index.js +2 -2
  28. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  29. package/dist/components/ui/index.cjs +3 -3
  30. package/dist/components/ui/index.js +2 -2
  31. package/dist/hooks/index.cjs +4 -2
  32. package/dist/hooks/index.cjs.map +1 -1
  33. package/dist/hooks/index.d.ts +1 -0
  34. package/dist/hooks/index.d.ts.map +1 -1
  35. package/dist/hooks/index.js +3 -1
  36. package/dist/hooks/use-near-viewport.d.ts +42 -0
  37. package/dist/hooks/use-near-viewport.d.ts.map +1 -0
  38. package/dist/index.cjs +19 -3
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.js +24 -8
  41. package/package.json +2 -2
  42. package/src/components/features/entity-video-section.tsx +175 -0
  43. package/src/components/features/index.ts +9 -2
  44. package/src/components/features/video-bites-display.tsx +216 -0
  45. package/src/components/features/video-ratio-tabs.tsx +174 -0
  46. package/src/components/features/video.tsx +474 -0
  47. package/src/components/media-carousel.tsx +43 -236
  48. package/src/components/shared/product-release/release-detail-page.tsx +26 -19
  49. package/src/hooks/index.ts +3 -0
  50. package/src/hooks/use-near-viewport.ts +118 -0
  51. package/dist/chunk-6LDN3CIY.js.map +0 -1
  52. package/dist/chunk-C6ZMI4UB.cjs.map +0 -1
  53. package/dist/chunk-KB2N44BY.js.map +0 -1
  54. package/dist/chunk-WX7PT5C7.cjs.map +0 -1
  55. package/dist/components/features/video-player.d.ts +0 -47
  56. package/dist/components/features/video-player.d.ts.map +0 -1
  57. package/dist/components/features/youtube-embed.d.ts +0 -17
  58. package/dist/components/features/youtube-embed.d.ts.map +0 -1
  59. package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
  60. package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
  61. package/dist/utils/lite-youtube-embed.d.ts +0 -9
  62. package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
  63. package/src/components/features/.video-player.md +0 -44
  64. package/src/components/features/.youtube-embed.md +0 -40
  65. package/src/components/features/__tests__/video-player.test.tsx +0 -142
  66. package/src/components/features/video-player.tsx +0 -1030
  67. package/src/components/features/youtube-embed.tsx +0 -275
  68. package/src/utils/lite-youtube-embed-stub.tsx +0 -21
  69. package/src/utils/lite-youtube-embed.tsx +0 -46
@@ -1,1030 +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
- * Extract a first-frame poster from a direct video file URL via an
23
- * off-DOM `<video>` + `<canvas>`. This is the most reliable way to get
24
- * a real thumbnail for ANY video regardless of encoding or moov atom
25
- * placement (which is what breaks the `#t=0.5` URL-hash trick on long
26
- * videos whose moov atom is written at the end of the file).
27
- *
28
- * Sequence:
29
- * 1. Create hidden `<video>` with `crossOrigin="anonymous"` (REQUIRED
30
- * before setting src, otherwise the canvas is tainted).
31
- * 2. `loadedmetadata` → seek to 10% of duration (clamped 2s–30s).
32
- * 3. `seeked` → draw the frame to a canvas → export as data URL.
33
- * 4. Clean up the hidden video immediately.
34
- *
35
- * Falls back to `null` on CORS failure, decode error, or unsupported
36
- * video format. Callers should treat `null` as "no poster — show play
37
- * overlay without a thumbnail."
38
- */
39
- function useVideoFirstFramePoster(
40
- url: string | undefined,
41
- enabled: boolean,
42
- ): string | null {
43
- const [poster, setPoster] = useState<string | null>(null);
44
-
45
- useEffect(() => {
46
- if (!enabled || !url) {
47
- setPoster(null);
48
- return;
49
- }
50
- // Only direct video files — skip YouTube, Vimeo, HLS, etc.
51
- const isDirectFile = /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
52
- if (!isDirectFile) {
53
- setPoster(null);
54
- return;
55
- }
56
-
57
- let cancelled = false;
58
- const video = document.createElement('video');
59
- video.crossOrigin = 'anonymous';
60
- video.preload = 'metadata';
61
- video.muted = true;
62
- video.playsInline = true;
63
- // Tags this hidden element so regression tests can detect the poster-extractor
64
- // path being entered without false-positives from any other code that might
65
- // happen to set crossOrigin on a <video>. See video-player.test.tsx.
66
- video.setAttribute('data-poster-extractor', 'true');
67
-
68
- const cleanup = () => {
69
- video.removeAttribute('src');
70
- try { video.load(); } catch { /* noop */ }
71
- };
72
-
73
- const onLoadedMetadata = () => {
74
- if (cancelled) return;
75
- // Seek to 10% of the video's duration (clamped between 2s and 30s).
76
- // Many videos have a dark fade-in during the first 1-2 seconds;
77
- // 10% is far enough to land on real content for both short clips
78
- // (30s → 3s) and long recordings (30min → 3min, clamped to 30s).
79
- const dur = video.duration || 10;
80
- const target = Math.max(2, Math.min(dur * 0.1, 30));
81
- video.currentTime = target;
82
- };
83
-
84
- const onSeeked = () => {
85
- if (cancelled) return;
86
- try {
87
- const canvas = document.createElement('canvas');
88
- canvas.width = video.videoWidth;
89
- canvas.height = video.videoHeight;
90
- const ctx = canvas.getContext('2d');
91
- if (!ctx || !canvas.width || !canvas.height) {
92
- cleanup();
93
- return;
94
- }
95
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
96
- const dataUrl = canvas.toDataURL('image/jpeg', 0.82);
97
- if (!cancelled) setPoster(dataUrl);
98
- } catch (err) {
99
- // Tainted canvas (CORS) or decode failure — silently give up.
100
- console.warn('[VideoPlayer] first-frame extraction failed', err);
101
- } finally {
102
- cleanup();
103
- }
104
- };
105
-
106
- const onError = () => {
107
- cleanup();
108
- };
109
-
110
- video.addEventListener('loadedmetadata', onLoadedMetadata);
111
- video.addEventListener('seeked', onSeeked);
112
- video.addEventListener('error', onError);
113
- video.src = url;
114
-
115
- return () => {
116
- cancelled = true;
117
- video.removeEventListener('loadedmetadata', onLoadedMetadata);
118
- video.removeEventListener('seeked', onSeeked);
119
- video.removeEventListener('error', onError);
120
- cleanup();
121
- };
122
- }, [url, enabled]);
123
-
124
- return poster;
125
- }
126
-
127
- /**
128
- * Global CSS injection for iOS Safari native fullscreen captions.
129
- * Safari hides ::-webkit-media-text-track-container in fullscreen by default.
130
- * This forces it visible. Injected once, persists for the page lifetime.
131
- */
132
- let webkitCaptionCSSInjected = false;
133
- function ensureWebkitCaptionCSS() {
134
- if (webkitCaptionCSSInjected || typeof document === 'undefined') return;
135
- const style = document.createElement('style');
136
- style.textContent = `
137
- video::-webkit-media-text-track-container {
138
- display: block !important;
139
- visibility: visible !important;
140
- overflow: visible !important;
141
- }
142
- video::-webkit-media-text-track-display {
143
- white-space: pre-line;
144
- }
145
- `;
146
- document.head.appendChild(style);
147
- webkitCaptionCSSInjected = true;
148
- }
149
-
150
- // =============================================================================
151
- // SRT Subtitle Overlay System
152
- // =============================================================================
153
- // react-player's config.file.tracks is broken (GitHub #1623, #1162, #329).
154
- // The industry standard is a custom subtitle overlay synced via onProgress.
155
- // On iOS native fullscreen, we inject a <track> element so the native player
156
- // shows captions (our overlay is not visible in webkitEnterFullscreen).
157
-
158
- interface SrtCue {
159
- from: number; // milliseconds
160
- to: number; // milliseconds
161
- text: string;
162
- }
163
-
164
- /**
165
- * Parse SRT content into timestamped cues.
166
- * SRT format: sequential blocks of [index]\n[start --> end]\n[text]\n\n
167
- */
168
- function parseSrt(srt: string): SrtCue[] {
169
- const cues: SrtCue[] = [];
170
- const blocks = srt.replace(/\r\n/g, '\n').trim().split(/\n\n+/);
171
-
172
- for (const block of blocks) {
173
- const lines = block.split('\n');
174
- // Find the timestamp line (contains " --> ")
175
- const tsIndex = lines.findIndex(l => l.includes(' --> '));
176
- if (tsIndex === -1) continue;
177
-
178
- const [startStr, endStr] = lines[tsIndex].split(' --> ');
179
- const from = parseSrtTimestamp(startStr?.trim());
180
- const to = parseSrtTimestamp(endStr?.trim());
181
- if (from === null || to === null) continue;
182
-
183
- // Everything after the timestamp line is the subtitle text
184
- const text = lines.slice(tsIndex + 1).join('\n').trim();
185
- if (text) cues.push({ from, to, text });
186
- }
187
-
188
- return cues;
189
- }
190
-
191
- /** Parse "HH:MM:SS,mmm" to milliseconds */
192
- function parseSrtTimestamp(ts: string | undefined): number | null {
193
- if (!ts) return null;
194
- const match = ts.match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/);
195
- if (!match) return null;
196
- return (
197
- parseInt(match[1]) * 3600000 +
198
- parseInt(match[2]) * 60000 +
199
- parseInt(match[3]) * 1000 +
200
- parseInt(match[4])
201
- );
202
- }
203
-
204
- /**
205
- * Hook: parse SRT and provide the active subtitle text for the current time.
206
- * Uses linear scan — performant for typical SRT files (< 500 cues).
207
- */
208
- function useSubtitleOverlay(srtContent: string | undefined) {
209
- const cues = useMemo(() => srtContent ? parseSrt(srtContent) : [], [srtContent]);
210
- const [activeText, setActiveText] = useState<string | null>(null);
211
-
212
- const updateTime = useCallback((playedSeconds: number) => {
213
- const timeMs = playedSeconds * 1000;
214
- // Linear scan is fine for typical SRT files (<500 cues)
215
- const active = cues.find(c => timeMs >= c.from && timeMs <= c.to);
216
- setActiveText(active?.text ?? null);
217
- }, [cues]);
218
-
219
- return { activeText, updateTime, hasCues: cues.length > 0 };
220
- }
221
-
222
- interface VideoPlayerProps {
223
- url: string;
224
- title?: string;
225
- poster?: string;
226
- className?: string;
227
- showTitle?: boolean;
228
- autoPlay?: boolean;
229
- loop?: boolean;
230
- muted?: boolean;
231
- useNativeAspectRatio?: boolean;
232
- /** SRT subtitle content string. Parsed and rendered as overlay synced via onProgress. */
233
- srtContent?: string;
234
- /** HTTPS URL to a VTT/SRT captions file. Required for iOS native fullscreen subtitles
235
- * (iOS Safari does not support blob: URLs for <track> elements). */
236
- captionsUrl?: string;
237
- /** Label for the subtitle track (default: 'English') */
238
- subtitleLabel?: string;
239
- /**
240
- * When true, defers all network activity for the video until the user clicks play:
241
- * - Skips the hidden-video first-frame poster extraction (no canvas dance)
242
- * - Sets `preload="none"` on the underlying `<video>` (zero bytes fetched on mount)
243
- * - On play-click, switches `preload="auto"` and synchronously calls `videoEl.play()`
244
- * inside the click handler to preserve the iOS Safari user-activation chain
245
- *
246
- * Use for testimonial/marketing videos where:
247
- * - The video may never be played (most visitors don't click)
248
- * - A real poster image is available (`poster` prop) so the user has something to look at
249
- * - You want to eliminate the redundant hidden-video metadata fetch that races
250
- * ReactPlayer's own metadata fetch
251
- *
252
- * Interaction semantics:
253
- * - `lazyMount + autoPlay` → `autoPlay` wins; lazyMount is ignored (caller has explicitly committed)
254
- * - `lazyMount + loop` → orthogonal; loop applies after first play
255
- * - `lazyMount + useNativeAspectRatio` → orthogonal; native aspect comes from the existing `<video>`
256
- *
257
- * Behavior change for non-lazy callers (lazyMount=false): once `hasStarted=true`, preload
258
- * dynamically promotes from `'metadata'` to `'auto'` for aggressive buffering. Browser
259
- * dedupes ranged requests on the same resource so this is benign in practice.
260
- *
261
- * @default false
262
- */
263
- lazyMount?: boolean;
264
- }
265
-
266
- /** Playback speed options */
267
- const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
268
-
269
- /**
270
- * Grace period after a lazyMount click before we surface a play() failure to
271
- * the user via the error UI. Calibrated for the typical case where a slow
272
- * Supabase Storage origin takes 1-2s to first-byte, then ReactPlayer's React
273
- * state-driven .play() retry takes another ~500ms. Anything still paused
274
- * AND carrying an `HTMLMediaElement.error` after this window is genuinely
275
- * broken (codec mismatch, 404, CORS), not just slow.
276
- */
277
- const LAZY_MOUNT_PLAY_FAILURE_GRACE_MS = 2000;
278
-
279
- /** Format seconds to M:SS or H:MM:SS display */
280
- function formatTime(secs: number): string {
281
- if (!secs || !isFinite(secs)) return '0:00';
282
- const h = Math.floor(secs / 3600);
283
- const m = Math.floor((secs % 3600) / 60);
284
- const s = Math.floor(secs % 60);
285
- if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
286
- return `${m}:${s.toString().padStart(2, '0')}`;
287
- }
288
-
289
- export const VideoPlayer: React.FC<VideoPlayerProps> = ({
290
- url,
291
- title,
292
- poster,
293
- className = "",
294
- showTitle = false,
295
- autoPlay = false,
296
- loop = false,
297
- muted = false,
298
- useNativeAspectRatio = false,
299
- srtContent,
300
- captionsUrl,
301
- subtitleLabel,
302
- lazyMount = false,
303
- }) => {
304
- // =========================================================================
305
- // Core state
306
- // =========================================================================
307
- const [hasError, setHasError] = useState(false);
308
- const [isPlaying, setIsPlaying] = useState(autoPlay);
309
- const [mounted, setMounted] = useState(false);
310
- const [hasStarted, setHasStarted] = useState(autoPlay);
311
- const playerRef = useRef<ReactPlayer | null>(null);
312
- const containerRef = useRef<HTMLDivElement | null>(null);
313
-
314
- // Custom controls state
315
- const [played, setPlayed] = useState(0);
316
- const [loaded, setLoaded] = useState(0);
317
- const [duration, setDuration] = useState(0);
318
- const [volume, setVolume] = useState(0.8);
319
- const [prevVolume, setPrevVolume] = useState(0.8);
320
- const [isMuted, setIsMuted] = useState(muted);
321
- const [isBuffering, setIsBuffering] = useState(false);
322
- const [showControls, setShowControls] = useState(true);
323
- const hideTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
324
- const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
325
- const iosFullscreenTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
326
- // Tracks the iOS lazyMount play() failure-escalation timer so unmount can
327
- // cancel it. Without this, a click-then-navigate within the grace window
328
- // would fire `setHasError` on a torn-down component (React 18 warns).
329
- const lazyMountFailureTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
330
-
331
- // Subtitle + fullscreen state
332
- const [captionsEnabled, setCaptionsEnabled] = useState(true);
333
- const [isFullscreen, setIsFullscreen] = useState(false);
334
- const { activeText, updateTime, hasCues } = useSubtitleOverlay(srtContent);
335
-
336
- // =========================================================================
337
- // Fullscreen — industry standard dual-mode (Plyr / Video.js / Vidstack pattern)
338
- //
339
- // Desktop/Android: container.requestFullscreen() — custom controls survive
340
- // iOS Safari: video.webkitEnterFullscreen() — native iOS controls take over
341
- //
342
- // Every battle-tested player (Plyr, Video.js, Vidstack, YouTube mobile web)
343
- // surrenders custom controls to iOS in fullscreen. The Fullscreen API doesn't
344
- // support divs on iOS, and CSS simulation has too many edge cases (notch,
345
- // address bar, orientation). Native iOS fullscreen is the correct UX.
346
- // =========================================================================
347
- useEffect(() => {
348
- const onChange = () => {
349
- const fsEl = document.fullscreenElement || (document as any).webkitFullscreenElement;
350
- setIsFullscreen(!!fsEl);
351
- };
352
- document.addEventListener('fullscreenchange', onChange);
353
- document.addEventListener('webkitfullscreenchange', onChange);
354
- return () => {
355
- document.removeEventListener('fullscreenchange', onChange);
356
- document.removeEventListener('webkitfullscreenchange', onChange);
357
- };
358
- }, []);
359
-
360
- /** Activate all caption/subtitle tracks on a video element */
361
- const activateCaptionTracks = useCallback((video: HTMLVideoElement) => {
362
- for (let i = 0; i < video.textTracks.length; i++) {
363
- if (video.textTracks[i].kind === 'captions' || video.textTracks[i].kind === 'subtitles') {
364
- video.textTracks[i].mode = 'showing';
365
- }
366
- }
367
- }, []);
368
-
369
- /**
370
- * iOS native fullscreen with caption support.
371
- * Steps (validated against Plyr, Video.js, Mux, Vidstack source):
372
- * 1. Inject CSS to force ::-webkit-media-text-track-container visible
373
- * 2. Create <track> element with real HTTPS captionsUrl
374
- * 3. Append to <video> with default attribute
375
- * 4. Set textTrack.mode = 'showing'
376
- * 5. Wait for track to load THEN call webkitEnterFullscreen
377
- */
378
- const enterNativeVideoFullscreen = useCallback(() => {
379
- const video = playerRef.current?.getInternalPlayer() as HTMLVideoElement | null;
380
- if (!video || !(video as any).webkitEnterFullscreen) return;
381
-
382
- ensureWebkitCaptionCSS();
383
-
384
- // Determine the captions source URL for native iOS fullscreen.
385
- // iOS Safari does NOT support blob: URLs for <track> elements (WebKit TextTrackLoader limitation).
386
- // A real HTTPS URL is required. captionsUrl is served by /api/captions/[entityType]/[entityId].
387
- const trackSrc = captionsUrl || null;
388
-
389
- if (!trackSrc) {
390
- // No captions URL available — enter fullscreen without subtitles
391
- try { (video as any).webkitEnterFullscreen(); } catch { /* requires user gesture */ }
392
- return;
393
- }
394
-
395
- // Clean up previous track + timer
396
- const old = video.querySelector('track[data-native-cc]');
397
- if (old) old.remove();
398
- clearTimeout(iosFullscreenTimerRef.current);
399
-
400
- // Create <track> element with real HTTPS URL
401
- const track = document.createElement('track');
402
- track.kind = 'captions';
403
- track.label = subtitleLabel || 'English';
404
- track.srclang = 'en';
405
- track.default = true;
406
- track.setAttribute('data-native-cc', 'true');
407
-
408
- video.appendChild(track);
409
- track.src = trackSrc;
410
-
411
- // Wait for track to load, then enter fullscreen
412
- let entered = false;
413
- const doFullscreen = () => {
414
- if (entered) return;
415
- entered = true;
416
- activateCaptionTracks(video);
417
- try { (video as any).webkitEnterFullscreen(); } catch { /* requires user gesture */ }
418
- };
419
-
420
- track.addEventListener('load', doFullscreen, { once: true });
421
- iosFullscreenTimerRef.current = setTimeout(() => {
422
- track.removeEventListener('load', doFullscreen);
423
- doFullscreen();
424
- }, 500);
425
- }, [captionsUrl, subtitleLabel, activateCaptionTracks]);
426
-
427
- const toggleFullscreen = useCallback(() => {
428
- const container = containerRef.current;
429
- if (!container) return;
430
-
431
- if (isFullscreen) {
432
- try {
433
- if (document.exitFullscreen) document.exitFullscreen();
434
- else if ((document as any).webkitExitFullscreen) (document as any).webkitExitFullscreen();
435
- } catch { /* exit may fail if not in fullscreen */ }
436
- return;
437
- }
438
-
439
- // Enter — try container first (desktop/Android), then native video (iOS)
440
- if (container.requestFullscreen) {
441
- container.requestFullscreen().catch(() => enterNativeVideoFullscreen());
442
- } else if ((container as any).webkitRequestFullscreen) {
443
- (container as any).webkitRequestFullscreen();
444
- } else {
445
- enterNativeVideoFullscreen();
446
- }
447
- }, [isFullscreen, enterNativeVideoFullscreen]);
448
-
449
- // =========================================================================
450
- // Volume
451
- // =========================================================================
452
- const toggleMute = useCallback(() => {
453
- if (isMuted) {
454
- setIsMuted(false);
455
- setVolume(prevVolume || 0.5);
456
- } else {
457
- setPrevVolume(volume);
458
- setIsMuted(true);
459
- setVolume(0);
460
- }
461
- }, [isMuted, volume, prevVolume]);
462
-
463
- const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
464
- const val = parseFloat(e.target.value);
465
- setVolume(val);
466
- setIsMuted(val === 0);
467
- if (val > 0) setPrevVolume(val);
468
- }, []);
469
-
470
- // =========================================================================
471
- // Auto-hide controls (3s inactivity)
472
- // Desktop: mouse move shows controls, hide after 3s
473
- // Mobile: tap toggles controls visibility, auto-hide after 3s when shown
474
- // =========================================================================
475
- const startHideTimer = useCallback(() => {
476
- clearTimeout(hideTimeoutRef.current);
477
- if (isPlaying) {
478
- hideTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
479
- }
480
- }, [isPlaying]);
481
-
482
- // Desktop: mouse movement
483
- const handleMouseMove = useCallback(() => {
484
- setShowControls(true);
485
- startHideTimer();
486
- }, [startHideTimer]);
487
-
488
- // Mobile: tap on video area toggles controls (not on controls bar itself)
489
- const handleTouchToggle = useCallback(() => {
490
- if (!hasStarted) return;
491
- setShowControls(prev => {
492
- const next = !prev;
493
- clearTimeout(hideTimeoutRef.current);
494
- if (next && isPlaying) {
495
- hideTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
496
- }
497
- return next;
498
- });
499
- }, [hasStarted, isPlaying]);
500
-
501
- // =========================================================================
502
- // Keyboard shortcuts (Space, Arrow keys, M, F)
503
- // =========================================================================
504
- useEffect(() => {
505
- if (!hasStarted) return;
506
- const el = containerRef.current;
507
- if (!el) return;
508
-
509
- const onKey = (e: KeyboardEvent) => {
510
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
511
- switch (e.key) {
512
- case ' ':
513
- case 'k':
514
- e.preventDefault();
515
- setIsPlaying(prev => !prev);
516
- break;
517
- case 'ArrowLeft':
518
- e.preventDefault();
519
- playerRef.current?.seekTo(Math.max(0, (playerRef.current?.getCurrentTime() ?? 0) - 5), 'seconds');
520
- break;
521
- case 'ArrowRight':
522
- e.preventDefault();
523
- playerRef.current?.seekTo(Math.min(duration, (playerRef.current?.getCurrentTime() ?? 0) + 5), 'seconds');
524
- break;
525
- case 'ArrowUp':
526
- e.preventDefault();
527
- setVolume(v => { const nv = Math.min(1, v + 0.1); setIsMuted(false); return nv; });
528
- break;
529
- case 'ArrowDown':
530
- e.preventDefault();
531
- setVolume(v => { const nv = Math.max(0, v - 0.1); if (nv === 0) setIsMuted(true); return nv; });
532
- break;
533
- case 'm': case 'M':
534
- e.preventDefault();
535
- toggleMute();
536
- break;
537
- case 'f': case 'F':
538
- e.preventDefault();
539
- toggleFullscreen();
540
- break;
541
- case 'c': case 'C':
542
- e.preventDefault();
543
- setCaptionsEnabled(prev => !prev);
544
- break;
545
- }
546
- };
547
-
548
- el.addEventListener('keydown', onKey);
549
- return () => el.removeEventListener('keydown', onKey);
550
- }, [hasStarted, duration, toggleMute, toggleFullscreen]);
551
-
552
- // =========================================================================
553
- // Helpers
554
- // =========================================================================
555
-
556
- // Seek preview tooltip
557
- const [seekPreview, setSeekPreview] = useState<{ fraction: number; x: number } | null>(null);
558
-
559
- // Playback speed
560
- const [playbackRate, setPlaybackRate] = useState(1);
561
- const cycleSpeed = useCallback(() => {
562
- setPlaybackRate(prev => {
563
- const idx = SPEED_OPTIONS.indexOf(prev as typeof SPEED_OPTIONS[number]);
564
- return SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
565
- });
566
- }, []);
567
-
568
- // =========================================================================
569
- // Progress bar: click-to-seek, drag-to-scrub (desktop), touch-to-scrub (mobile)
570
- // YouTube pattern: mouseDown starts tracking, mouseMove on document updates,
571
- // mouseUp stops tracking. This allows dragging outside the bar.
572
- // =========================================================================
573
- const progressBarRef = useRef<HTMLDivElement | null>(null);
574
- const isDraggingRef = useRef(false);
575
- const dragListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
576
-
577
- const seekToClientX = useCallback((clientX: number) => {
578
- const rect = progressBarRef.current?.getBoundingClientRect();
579
- if (!rect) return;
580
- const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
581
- setPlayed(fraction);
582
- playerRef.current?.seekTo(fraction, 'fraction');
583
- }, []);
584
-
585
- // Desktop: mouseDown on bar starts drag, mouseMove/mouseUp on document
586
- const handleProgressMouseDown = useCallback((e: React.MouseEvent) => {
587
- e.stopPropagation();
588
- e.preventDefault();
589
- isDraggingRef.current = true;
590
- seekToClientX(e.clientX);
591
-
592
- // Clean up any existing listeners first
593
- if (dragListenersRef.current) {
594
- document.removeEventListener('mousemove', dragListenersRef.current.move);
595
- document.removeEventListener('mouseup', dragListenersRef.current.up);
596
- }
597
-
598
- const onMouseMove = (ev: MouseEvent) => {
599
- if (!isDraggingRef.current) return;
600
- seekToClientX(ev.clientX);
601
- };
602
- const onMouseUp = () => {
603
- isDraggingRef.current = false;
604
- document.removeEventListener('mousemove', onMouseMove);
605
- document.removeEventListener('mouseup', onMouseUp);
606
- dragListenersRef.current = null;
607
- };
608
- dragListenersRef.current = { move: onMouseMove, up: onMouseUp };
609
- document.addEventListener('mousemove', onMouseMove);
610
- document.addEventListener('mouseup', onMouseUp);
611
- }, [seekToClientX]);
612
-
613
- // Mobile: touchStart + touchMove on the bar
614
- const handleProgressTouchStart = useCallback((e: React.TouchEvent) => {
615
- e.stopPropagation();
616
- const touch = e.touches[0];
617
- if (touch) seekToClientX(touch.clientX);
618
- }, [seekToClientX]);
619
-
620
- const handleProgressTouchMove = useCallback((e: React.TouchEvent) => {
621
- e.stopPropagation();
622
- const touch = e.touches[0];
623
- if (touch) seekToClientX(touch.clientX);
624
- }, [seekToClientX]);
625
-
626
- const handleProgressKeyDown = useCallback((e: React.KeyboardEvent) => {
627
- if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(Math.min(duration, (playerRef.current?.getCurrentTime() ?? 0) + 5), 'seconds'); }
628
- if (e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(Math.max(0, (playerRef.current?.getCurrentTime() ?? 0) - 5), 'seconds'); }
629
- if (e.key === 'Home') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(0, 'seconds'); }
630
- if (e.key === 'End') { e.preventDefault(); e.stopPropagation(); playerRef.current?.seekTo(duration, 'seconds'); }
631
- }, [duration]);
632
-
633
- const handleProgressHover = useCallback((e: React.MouseEvent) => {
634
- if (isDraggingRef.current) return; // Don't update tooltip while dragging
635
- const rect = e.currentTarget.getBoundingClientRect();
636
- const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
637
- const x = Math.max(30, Math.min(rect.width - 30, e.clientX - rect.left));
638
- setSeekPreview({ fraction, x });
639
- }, []);
640
-
641
- // Desktop: Double-click = fullscreen, single click = play/pause
642
- // Mobile: Single tap = toggle controls (handled by onTouchEnd)
643
- const isTouchRef = useRef(false);
644
-
645
- const handleContainerClick = useCallback((e: React.MouseEvent) => {
646
- // Skip click events that originated from touch (mobile)
647
- if (isTouchRef.current) { isTouchRef.current = false; return; }
648
- if ((e.target as HTMLElement).closest('.video-controls-bar')) return;
649
- if (!hasStarted) return;
650
- if (clickTimerRef.current) {
651
- clearTimeout(clickTimerRef.current);
652
- clickTimerRef.current = undefined;
653
- toggleFullscreen();
654
- } else {
655
- clickTimerRef.current = setTimeout(() => {
656
- clickTimerRef.current = undefined;
657
- setIsPlaying(prev => !prev);
658
- }, 250);
659
- }
660
- }, [hasStarted, toggleFullscreen]);
661
-
662
- const handleContainerTouchEnd = useCallback((e: React.TouchEvent) => {
663
- if ((e.target as HTMLElement).closest('.video-controls-bar')) return;
664
- isTouchRef.current = true; // Suppress the subsequent click event
665
- if (!hasStarted) return;
666
- handleTouchToggle();
667
- }, [hasStarted, handleTouchToggle]);
668
-
669
- // Extract first frame as poster when no explicit poster provided.
670
- // Skipped entirely in lazyMount mode (no hidden-video element, no canvas dance).
671
- const extractedPoster = useVideoFirstFramePoster(url, !lazyMount && !hasStarted && !poster);
672
- const effectivePoster = poster || extractedPoster || undefined;
673
- const posterBgColor = useImageEdgeColor(effectivePoster);
674
-
675
- useEffect(() => {
676
- setMounted(true);
677
- return () => {
678
- // Clean up all pending timers, drag listeners, and state on unmount
679
- clearTimeout(clickTimerRef.current);
680
- clearTimeout(hideTimeoutRef.current);
681
- clearTimeout(iosFullscreenTimerRef.current);
682
- clearTimeout(lazyMountFailureTimerRef.current);
683
- isDraggingRef.current = false;
684
- if (dragListenersRef.current) {
685
- document.removeEventListener('mousemove', dragListenersRef.current.move);
686
- document.removeEventListener('mouseup', dragListenersRef.current.up);
687
- dragListenersRef.current = null;
688
- }
689
- };
690
- }, []);
691
-
692
- // Re-activate caption tracks when iOS enters native fullscreen
693
- // (belt-and-suspenders — ensures tracks stay visible)
694
- useEffect(() => {
695
- if (!hasStarted) return;
696
- const video = playerRef.current?.getInternalPlayer() as HTMLVideoElement | null;
697
- if (!video) return;
698
-
699
- const onBeginFS = () => activateCaptionTracks(video);
700
-
701
- video.addEventListener('webkitbeginfullscreen', onBeginFS);
702
- return () => video.removeEventListener('webkitbeginfullscreen', onBeginFS);
703
- }, [hasStarted, activateCaptionTracks]);
704
-
705
- const handleError = useCallback(() => setHasError(true), []);
706
- const handlePlay = useCallback(() => { setIsPlaying(true); setHasStarted(true); }, []);
707
- const handlePause = useCallback(() => setIsPlaying(false), []);
708
- const handleEnded = useCallback(() => setIsPlaying(false), []);
709
- const handlePlayClick = useCallback(() => {
710
- // iOS user-activation belt-and-suspenders: when lazyMount is on, the React state
711
- // updates below cause a re-render that promotes preload to 'auto' and triggers
712
- // ReactPlayer's internal play() — but that play() call may run in a separate task
713
- // from the click event, which iOS Safari treats as non-user-initiated.
714
- // Calling .play() synchronously here preserves the user-activation flag.
715
- //
716
- // Guards:
717
- // - `instanceof HTMLVideoElement` — `getInternalPlayer()` returns YouTube/Vimeo
718
- // iframe wrappers when those URL types are passed; we only want the native
719
- // <video> element for the file player path.
720
- // - `.catch` — escalates persistent failures to the error UI after a grace
721
- // period so users don't stare at a frozen poster forever (HEVC-in-MP4 on
722
- // Chrome/Firefox is the common cause; the admin form warns about this).
723
- // - Failure timer is stored in a ref so unmount can cancel it (otherwise
724
- // a click-then-navigate within the grace window would fire setHasError
725
- // on a torn-down component).
726
- if (lazyMount) {
727
- const native = playerRef.current?.getInternalPlayer();
728
- if (native instanceof HTMLVideoElement) {
729
- native.play().catch(() => {
730
- // Allow React's state-driven play() one chance, then surface the failure.
731
- clearTimeout(lazyMountFailureTimerRef.current);
732
- lazyMountFailureTimerRef.current = setTimeout(() => {
733
- if (native.paused && native.error) setHasError(true);
734
- }, LAZY_MOUNT_PLAY_FAILURE_GRACE_MS);
735
- });
736
- } else if (process.env.NODE_ENV !== 'production') {
737
- console.warn('[VideoPlayer] lazyMount sync play(): no native HTMLVideoElement yet');
738
- }
739
- }
740
- setHasStarted(true);
741
- setIsPlaying(true);
742
- }, [lazyMount]);
743
-
744
- const handleProgress = useCallback(({ played: p, loaded: l, playedSeconds }: { played: number; loaded: number; playedSeconds: number }) => {
745
- setPlayed(p);
746
- setLoaded(l);
747
- updateTime(playedSeconds);
748
- }, [updateTime]);
749
-
750
- const handleBuffer = useCallback(() => setIsBuffering(true), []);
751
- const handleBufferEnd = useCallback(() => setIsBuffering(false), []);
752
-
753
- // SSR placeholder — consistent loading skeleton
754
- if (!mounted) {
755
- return (
756
- <div className={`video-player-container ${className}`}>
757
- <div
758
- className="video-wrapper relative w-full"
759
- style={useNativeAspectRatio ? {} : { paddingBottom: '56.25%' }}
760
- >
761
- <div className={useNativeAspectRatio
762
- ? "bg-black rounded-md flex items-center justify-center min-h-[200px]"
763
- : "absolute inset-0 bg-black rounded-md flex items-center justify-center"
764
- }>
765
- <div className="w-16 h-16 rounded-full bg-ods-accent flex items-center justify-center shadow-lg">
766
- <PlayIcon size={24} className="ml-1 text-ods-text-on-accent" />
767
- </div>
768
- </div>
769
- </div>
770
- </div>
771
- );
772
- }
773
-
774
- if (hasError) {
775
- return (
776
- <div className={`video-player-error ${className}`}>
777
- <div className="error-state bg-ods-card border border-ods-border rounded-md p-6 text-center">
778
- <div className="error-icon flex justify-center mb-4">
779
- <AlertCircleIcon size={48} className="text-ods-attention-red-error" />
780
- </div>
781
- <div className="error-title font-sans font-semibold text-lg text-ods-attention-red-error mb-2">
782
- Video Unavailable
783
- </div>
784
- <div className="error-description font-sans text-sm text-ods-text-secondary mb-4">
785
- Unable to load video. The video may be unavailable or the format is not supported.
786
- </div>
787
- </div>
788
- </div>
789
- );
790
- }
791
-
792
- return (
793
- <div className={`video-player-container ${className}`}>
794
- {title && showTitle && (
795
- <div className="video-title font-sans text-lg font-medium text-ods-text-primary mb-3">
796
- {title}
797
- </div>
798
- )}
799
-
800
- {/* Container — fullscreened via Fullscreen API (desktop/Android) so custom controls + subtitles travel with video.
801
- On iOS Safari, native video.webkitEnterFullscreen() is used (Apple's controls take over — industry standard). */}
802
- <div
803
- ref={containerRef}
804
- tabIndex={0}
805
- role="region"
806
- aria-label={title || 'Video player'}
807
- 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 ${
808
- isFullscreen ? 'bg-black' : ''
809
- } ${isFullscreen && !showControls && isPlaying ? 'cursor-none' : ''}`}
810
- style={
811
- isFullscreen ? { width: '100%', height: '100%' }
812
- : useNativeAspectRatio ? {}
813
- : { paddingBottom: '56.25%' }
814
- }
815
- onMouseMove={handleMouseMove}
816
- onMouseLeave={startHideTimer}
817
- onTouchEnd={handleContainerTouchEnd}
818
- onClick={handleContainerClick}
819
- >
820
- {/* Initial play overlay */}
821
- {!hasStarted && !hasError && (
822
- <div className="absolute inset-0 cursor-pointer group z-20" onClick={handlePlayClick}>
823
- {effectivePoster && (
824
- <img src={effectivePoster} alt={title || 'Video thumbnail'}
825
- className="w-full h-full object-contain rounded-md"
826
- style={{ backgroundColor: posterBgColor }} />
827
- )}
828
- <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`}>
829
- <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">
830
- <PlayIcon size={24} className="ml-1 text-ods-text-on-accent" />
831
- </div>
832
- </div>
833
- </div>
834
- )}
835
-
836
- {/* ReactPlayer */}
837
- <div className={
838
- isFullscreen ? "video-player absolute inset-0"
839
- : useNativeAspectRatio ? "video-player rounded-md overflow-hidden border border-ods-border bg-ods-background"
840
- : "video-player absolute inset-0 rounded-md overflow-hidden border border-ods-border bg-ods-background"
841
- }>
842
- <ReactPlayer
843
- ref={playerRef}
844
- url={url}
845
- width="100%"
846
- height="100%"
847
- controls={false}
848
- playing={isPlaying}
849
- playbackRate={playbackRate}
850
- loop={loop}
851
- muted={isMuted}
852
- volume={isMuted ? 0 : volume}
853
- onError={handleError}
854
- onPlay={handlePlay}
855
- onPause={handlePause}
856
- onEnded={handleEnded}
857
- onDuration={setDuration}
858
- onBuffer={handleBuffer}
859
- onBufferEnd={handleBufferEnd}
860
- onProgress={handleProgress}
861
- progressInterval={200}
862
- config={{ file: { attributes: { controlsList: 'nodownload', playsInline: true, preload: lazyMount && !hasStarted ? 'none' : (hasStarted ? 'auto' : 'metadata') } } }}
863
- light={false}
864
- playsinline
865
- />
866
- </div>
867
-
868
- {/* Buffering spinner */}
869
- {isBuffering && hasStarted && (
870
- <div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
871
- <div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin" />
872
- </div>
873
- )}
874
-
875
- {/* Subtitle overlay — moves up/down in sync with controls bar visibility */}
876
- {captionsEnabled && activeText && hasStarted && (
877
- <div
878
- className="absolute left-[5%] right-[5%] text-center pointer-events-none z-10 transition-[bottom] duration-300 ease-in-out"
879
- style={{ bottom: (showControls || !isPlaying) ? 52 : 12 }}
880
- >
881
- <span
882
- className="inline-block bg-black/80 text-white leading-relaxed px-4 py-1.5 rounded font-sans font-medium whitespace-pre-line"
883
- style={{
884
- fontSize: isFullscreen ? 'clamp(20px, 3.3vh, 42px)' : 'clamp(15px, 3.3cqw, 26px)',
885
- maxWidth: '90%',
886
- textShadow: '0 1px 4px rgba(0,0,0,0.6)',
887
- WebkitTextStroke: '0.3px rgba(0,0,0,0.3)',
888
- }}
889
- >
890
- {activeText}
891
- </span>
892
- </div>
893
- )}
894
-
895
- {/* Custom Controls Bar — always on, CC button shown only when subtitles exist */}
896
- {hasStarted && (
897
- <div
898
- className={`video-controls-bar absolute bottom-0 left-0 right-0 z-30 transition-opacity duration-300 ${
899
- showControls || !isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
900
- }`}
901
- onTouchEnd={(e) => { e.stopPropagation(); startHideTimer(); }}
902
- >
903
- <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">
904
- {/* Progress bar — YouTube standard: 4px track, 24px+ touch hit area */}
905
- <div
906
- className="group/seek relative w-full h-6 cursor-pointer mb-0.5 flex items-center"
907
- ref={progressBarRef}
908
- role="slider"
909
- aria-label="Video progress"
910
- aria-valuenow={Math.round(played * 100)}
911
- aria-valuetext={`${formatTime(played * duration)} of ${formatTime(duration)}`}
912
- aria-valuemin={0}
913
- aria-valuemax={100}
914
- tabIndex={0}
915
- onMouseDown={handleProgressMouseDown}
916
- onTouchStart={handleProgressTouchStart}
917
- onTouchMove={handleProgressTouchMove}
918
- onTouchEnd={(e) => e.stopPropagation()}
919
- onMouseMove={handleProgressHover}
920
- onMouseLeave={() => setSeekPreview(null)}
921
- onKeyDown={handleProgressKeyDown}
922
- >
923
- {/* Seek preview tooltip */}
924
- {seekPreview && duration > 0 && (
925
- <div
926
- 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"
927
- style={{ left: seekPreview.x }}
928
- >
929
- {formatTime(seekPreview.fraction * duration)}
930
- </div>
931
- )}
932
- {/* Visual track — 4px, expands to 5px on hover (YouTube standard) */}
933
- <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">
934
- <div className="absolute inset-0 bg-white/20 rounded-full" />
935
- <div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-all"
936
- style={{ width: `${loaded * 100}%` }} />
937
- <div className="absolute inset-y-0 left-0 bg-white rounded-full"
938
- style={{ width: `${played * 100}%` }} />
939
- </div>
940
- {/* Scrub thumb — 12px (YouTube standard: 13px) */}
941
- <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"
942
- style={{ left: `calc(${played * 100}% - 6px)` }} />
943
- </div>
944
-
945
- {/* Controls row — 36px height (YouTube standard) */}
946
- <div className="flex items-center justify-between h-9">
947
- {/* Left: Play, Volume, Time */}
948
- <div className="flex items-center">
949
- {/* Play/Pause — 36×36 button, 20px icon */}
950
- <Button variant="transparent" size="icon"
951
- onClick={(e) => { e.stopPropagation(); setIsPlaying(prev => !prev); }}
952
- className="h-9 w-9 text-white hover:text-white/80 hover:bg-white/10"
953
- aria-label={isPlaying ? 'Pause (Space)' : 'Play (Space)'}>
954
- {isPlaying ? <PauseIcon size={20} color="white" /> : <PlayIcon size={20} color="white" />}
955
- </Button>
956
-
957
- {/* Volume: icon + hover-reveal slider */}
958
- <div className="group/vol flex items-center">
959
- <Button variant="transparent" size="icon"
960
- onClick={(e) => { e.stopPropagation(); toggleMute(); }}
961
- className="h-9 w-9 text-white hover:text-white/80 hover:bg-white/10"
962
- aria-label={isMuted ? 'Unmute (M)' : 'Mute (M)'}>
963
- {isMuted || volume === 0
964
- ? <VolumeOffIcon size={18} color="white" />
965
- : volume < 0.5
966
- ? <VolumeDownIcon size={18} color="white" />
967
- : <VolumeUpIcon size={18} color="white" />
968
- }
969
- </Button>
970
- <div className="w-0 overflow-hidden group-hover/vol:w-16 transition-all duration-200 flex items-center">
971
- <Input
972
- type="range" min={0} max={1} step={0.01}
973
- value={isMuted ? 0 : volume}
974
- onChange={handleVolumeChange}
975
- onClick={(e) => e.stopPropagation()}
976
- aria-label="Volume"
977
- className="w-14 ml-1"
978
- style={{ background: `linear-gradient(to right, white ${(isMuted ? 0 : volume) * 100}%, rgba(255,255,255,0.3) ${(isMuted ? 0 : volume) * 100}%)` }}
979
- />
980
- </div>
981
- </div>
982
-
983
- {/* Time — 12px font (YouTube standard) */}
984
- <span className="text-white/70 text-[12px] font-mono tabular-nums select-none ml-1.5">
985
- {formatTime(played * duration)} / {formatTime(duration)}
986
- </span>
987
- </div>
988
-
989
- {/* Right: Speed, CC, Fullscreen */}
990
- <div className="flex items-center">
991
- {/* Playback speed — compact text button */}
992
- <Button variant="transparent" size="small-legacy"
993
- onClick={(e) => { e.stopPropagation(); cycleSpeed(); }}
994
- className={`h-9 px-1.5 text-[11px] font-bold rounded hover:bg-white/10 ${
995
- playbackRate !== 1 ? 'text-white' : 'text-white/70 hover:text-white'
996
- }`}
997
- title="Playback speed"
998
- aria-label={`Playback speed ${playbackRate}x`}>
999
- {playbackRate}x
1000
- </Button>
1001
-
1002
- {hasCues && (
1003
- <Button variant="transparent" size="small-legacy"
1004
- onClick={(e) => { e.stopPropagation(); setCaptionsEnabled(prev => !prev); }}
1005
- className={`h-9 px-1.5 text-[11px] font-bold rounded ${
1006
- captionsEnabled ? 'bg-white text-black hover:bg-white/90' : 'text-white/50 hover:text-white hover:bg-white/10'
1007
- }`}
1008
- style={{ borderBottom: captionsEnabled ? '2px solid white' : '2px solid transparent' }}
1009
- title={captionsEnabled ? 'Hide captions (C)' : 'Show captions (C)'}
1010
- aria-label={captionsEnabled ? 'Hide captions' : 'Show captions'}>
1011
- CC
1012
- </Button>
1013
- )}
1014
-
1015
- <Button variant="transparent" size="icon"
1016
- onClick={(e) => { e.stopPropagation(); toggleFullscreen(); }}
1017
- className="h-9 w-9 text-white/80 hover:text-white hover:bg-white/10"
1018
- title={isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
1019
- aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
1020
- {isFullscreen ? <Collapse01Icon size={18} color="white" /> : <Expand01Icon size={18} color="white" />}
1021
- </Button>
1022
- </div>
1023
- </div>
1024
- </div>
1025
- </div>
1026
- )}
1027
- </div>
1028
- </div>
1029
- );
1030
- };