@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.
- package/dist/{chunk-AAX27BCR.js → chunk-DV2GT7RI.js} +3703 -4168
- package/dist/chunk-DV2GT7RI.js.map +1 -0
- package/dist/{chunk-L4T24AN4.cjs → chunk-JFGORTXV.cjs} +868 -1333
- package/dist/chunk-JFGORTXV.cjs.map +1 -0
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/features/entity-video-section.d.ts +54 -0
- package/dist/components/features/entity-video-section.d.ts.map +1 -0
- package/dist/components/features/index.cjs +18 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +4 -2
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +21 -5
- package/dist/components/features/video-bites-display.d.ts +38 -0
- package/dist/components/features/video-bites-display.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +62 -0
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +94 -0
- package/dist/components/features/video.d.ts.map +1 -0
- package/dist/components/index.cjs +18 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +21 -5
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +18 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +21 -5
- package/package.json +2 -2
- package/src/components/chat/chat-message-list.tsx +62 -18
- package/src/components/features/entity-video-section.tsx +175 -0
- package/src/components/features/index.ts +9 -2
- package/src/components/features/video-bites-display.tsx +216 -0
- package/src/components/features/video-ratio-tabs.tsx +174 -0
- package/src/components/features/video.tsx +474 -0
- package/src/components/media-carousel.tsx +43 -236
- package/src/components/shared/product-release/release-detail-page.tsx +26 -19
- package/dist/chunk-AAX27BCR.js.map +0 -1
- package/dist/chunk-L4T24AN4.cjs.map +0 -1
- package/dist/components/features/video-player.d.ts +0 -44
- package/dist/components/features/video-player.d.ts.map +0 -1
- package/dist/components/features/youtube-embed.d.ts +0 -31
- package/dist/components/features/youtube-embed.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
- package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed.d.ts +0 -9
- package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
- package/src/components/features/.video-player.md +0 -44
- package/src/components/features/.youtube-embed.md +0 -40
- package/src/components/features/video-player.tsx +0 -893
- package/src/components/features/youtube-embed.tsx +0 -158
- package/src/utils/lite-youtube-embed-stub.tsx +0 -21
- 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
|
-
};
|