@nationaldesignstudio/react 0.5.5 → 0.6.0
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/component-registry.md +1286 -40
- package/dist/index.d.ts +1858 -139
- package/dist/index.js +2388 -326
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +680 -0
- package/package.json +22 -2
- package/src/components/atoms/blurred-video-backdrop/blurred-video-backdrop.tsx +447 -0
- package/src/components/atoms/button/icon-button.tsx +10 -4
- package/src/components/atoms/select/select.tsx +202 -49
- package/src/components/atoms/video-player/caption-overlay.tsx +107 -0
- package/src/components/atoms/video-player/video-player.tsx +811 -0
- package/src/components/molecules/dialog/dialog.tsx +526 -0
- package/src/components/molecules/video-dialog/video-dialog.tsx +272 -0
- package/src/components/molecules/video-with-backdrop/video-with-backdrop.tsx +383 -0
- package/src/components/organisms/card/card.tsx +87 -12
- package/src/components/sections/hero/hero.tsx +35 -0
- package/src/hooks/index.ts +16 -0
- package/src/hooks/use-breakpoint.ts +145 -0
- package/src/hooks/use-captions.ts +247 -0
- package/src/hooks/use-video-keyboard.ts +230 -0
- package/src/lib/utils.ts +2 -2
- package/src/theme/index.ts +4 -0
- package/src/theme/theme-provider.tsx +48 -8
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MediaControlBar,
|
|
5
|
+
MediaController,
|
|
6
|
+
MediaLoadingIndicator,
|
|
7
|
+
MediaMuteButton,
|
|
8
|
+
MediaPlayButton,
|
|
9
|
+
MediaTimeDisplay,
|
|
10
|
+
MediaTimeRange,
|
|
11
|
+
MediaVolumeRange,
|
|
12
|
+
} from "media-chrome/react";
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
import { tv, type VariantProps } from "tailwind-variants";
|
|
15
|
+
import { useCaptions } from "@/hooks/use-captions";
|
|
16
|
+
import { useVideoKeyboard } from "@/hooks/use-video-keyboard";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import { CaptionOverlay } from "./caption-overlay";
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Types
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Cloudflare Stream configuration */
|
|
25
|
+
interface CloudflareConfig {
|
|
26
|
+
/** Cloudflare Stream video ID */
|
|
27
|
+
videoId: string;
|
|
28
|
+
/** Cloudflare customer code/subdomain */
|
|
29
|
+
customerCode: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Variant Definitions
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Video player container variants.
|
|
38
|
+
*/
|
|
39
|
+
const videoPlayerVariants = tv({
|
|
40
|
+
base: [
|
|
41
|
+
"relative",
|
|
42
|
+
"bg-black",
|
|
43
|
+
"overflow-hidden",
|
|
44
|
+
// Focus styling for keyboard navigation
|
|
45
|
+
"focus:outline-none",
|
|
46
|
+
"focus-visible:ring-2",
|
|
47
|
+
"focus-visible:ring-white/50",
|
|
48
|
+
],
|
|
49
|
+
variants: {
|
|
50
|
+
aspectRatio: {
|
|
51
|
+
"16/9": "aspect-video",
|
|
52
|
+
"4/3": "aspect-[4/3]",
|
|
53
|
+
"1/1": "aspect-square",
|
|
54
|
+
"9/16": "aspect-[9/16]",
|
|
55
|
+
auto: "",
|
|
56
|
+
},
|
|
57
|
+
rounded: {
|
|
58
|
+
none: "",
|
|
59
|
+
sm: "rounded-4",
|
|
60
|
+
md: "rounded-8",
|
|
61
|
+
lg: "rounded-12",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultVariants: {
|
|
65
|
+
aspectRatio: "16/9",
|
|
66
|
+
rounded: "none",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Media controller container styles.
|
|
72
|
+
* Uses CSS custom properties to configure media-chrome components.
|
|
73
|
+
* Styled to match DGA video player design with ghost-style buttons.
|
|
74
|
+
*/
|
|
75
|
+
const mediaControllerVariants = tv({
|
|
76
|
+
base: [
|
|
77
|
+
"absolute inset-0",
|
|
78
|
+
"w-full h-full",
|
|
79
|
+
// Button styling - transparent base, hover shows background
|
|
80
|
+
"[--media-control-background:transparent]",
|
|
81
|
+
"[--media-control-hover-background:var(--color-video-player-button-bg-hover)]",
|
|
82
|
+
"[--media-control-padding:8px]",
|
|
83
|
+
"[--media-control-height:36px]",
|
|
84
|
+
"[--media-button-icon-width:20px]",
|
|
85
|
+
"[--media-button-icon-height:20px]",
|
|
86
|
+
// Progress bar / range styling
|
|
87
|
+
"[--media-range-track-background:var(--color-video-player-progress-bg)]",
|
|
88
|
+
"[--media-range-bar-color:var(--color-video-player-progress-fill)]",
|
|
89
|
+
"[--media-range-track-height:4px]",
|
|
90
|
+
"[--media-range-track-border-radius:2px]",
|
|
91
|
+
"[--media-range-thumb-background:var(--color-video-player-progress-fill)]",
|
|
92
|
+
"[--media-range-thumb-height:12px]",
|
|
93
|
+
"[--media-range-thumb-width:12px]",
|
|
94
|
+
"[--media-range-thumb-border-radius:50%]",
|
|
95
|
+
// Text/icon colors
|
|
96
|
+
"[--media-icon-color:var(--color-video-player-controls-text)]",
|
|
97
|
+
"[--media-primary-color:var(--color-video-player-controls-text)]",
|
|
98
|
+
"[--media-secondary-color:transparent]",
|
|
99
|
+
"[--media-text-color:var(--color-video-player-controls-text)]",
|
|
100
|
+
"[--media-font-size:14px]",
|
|
101
|
+
// Time display styling
|
|
102
|
+
"[--media-time-display-background:transparent]",
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Media-chrome control button styles.
|
|
108
|
+
* Applied to media-play-button, media-mute-button, etc.
|
|
109
|
+
* Transparent by default, shows background on hover (ghost style).
|
|
110
|
+
*/
|
|
111
|
+
const mediaButtonStyles: React.CSSProperties = {
|
|
112
|
+
padding: "8px",
|
|
113
|
+
borderRadius: "50%",
|
|
114
|
+
// Tooltip styling - consistent across all buttons
|
|
115
|
+
"--media-tooltip-background": "var(--color-video-player-tooltip-bg)",
|
|
116
|
+
"--media-tooltip-arrow-display": "none",
|
|
117
|
+
"--media-tooltip-distance": "8px",
|
|
118
|
+
} as React.CSSProperties;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Time range styles matching DGA.
|
|
122
|
+
*/
|
|
123
|
+
const timeRangeStyles: React.CSSProperties = {
|
|
124
|
+
flex: 1,
|
|
125
|
+
background: "transparent",
|
|
126
|
+
// Preview tooltip styling - consistent with button tooltips
|
|
127
|
+
"--media-box-arrow-display": "none",
|
|
128
|
+
"--media-preview-box-margin": "0 0 8px 0",
|
|
129
|
+
"--media-preview-time-margin": "0 0 8px 0",
|
|
130
|
+
"--media-preview-time-background": "var(--color-video-player-tooltip-bg)",
|
|
131
|
+
} as React.CSSProperties;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Volume range styles.
|
|
135
|
+
*/
|
|
136
|
+
const volumeRangeStyles: React.CSSProperties = {
|
|
137
|
+
width: "80px",
|
|
138
|
+
background: "transparent",
|
|
139
|
+
// Tooltip styling - consistent with button tooltips
|
|
140
|
+
"--media-tooltip-background": "var(--color-video-player-tooltip-bg)",
|
|
141
|
+
"--media-tooltip-arrow-display": "none",
|
|
142
|
+
"--media-tooltip-distance": "8px",
|
|
143
|
+
} as React.CSSProperties;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Time display styles.
|
|
147
|
+
*/
|
|
148
|
+
const timeDisplayStyles: React.CSSProperties = {
|
|
149
|
+
background: "transparent",
|
|
150
|
+
fontFamily: "monospace",
|
|
151
|
+
fontSize: "14px",
|
|
152
|
+
color: "white",
|
|
153
|
+
whiteSpace: "nowrap",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Control bar variants.
|
|
158
|
+
* Note: Positioning is handled via inline styles to override web component defaults.
|
|
159
|
+
* Tailwind classes handle background color and visibility transitions.
|
|
160
|
+
*/
|
|
161
|
+
const controlBarVariants = tv({
|
|
162
|
+
base: [
|
|
163
|
+
// Layout handled in inline styles, but we need flex
|
|
164
|
+
"flex items-center",
|
|
165
|
+
// Background using semantic token
|
|
166
|
+
"bg-video-player-controls-bg",
|
|
167
|
+
// Animation
|
|
168
|
+
"transition-all duration-300",
|
|
169
|
+
],
|
|
170
|
+
variants: {
|
|
171
|
+
visible: {
|
|
172
|
+
true: "opacity-100 translate-y-0",
|
|
173
|
+
false: "opacity-0 translate-y-16 pointer-events-none",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
defaultVariants: {
|
|
177
|
+
visible: true,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Control button styles for custom buttons.
|
|
183
|
+
* Transparent by default, shows background on hover (ghost style).
|
|
184
|
+
*/
|
|
185
|
+
const controlButtonVariants = tv({
|
|
186
|
+
base: [
|
|
187
|
+
"flex items-center justify-center",
|
|
188
|
+
"p-8 rounded-full",
|
|
189
|
+
// Transparent by default, background on hover
|
|
190
|
+
"bg-transparent",
|
|
191
|
+
"text-video-player-controls-text",
|
|
192
|
+
"hover:bg-video-player-button-bg-hover",
|
|
193
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-video-player-progress-bg",
|
|
194
|
+
"transition-colors duration-150",
|
|
195
|
+
"cursor-pointer",
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Loading overlay variants.
|
|
201
|
+
*/
|
|
202
|
+
const loadingOverlayVariants = tv({
|
|
203
|
+
base: [
|
|
204
|
+
"absolute inset-0",
|
|
205
|
+
"flex items-center justify-center",
|
|
206
|
+
"bg-black/50",
|
|
207
|
+
"pointer-events-none",
|
|
208
|
+
"z-10",
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// HLS Hook (internal)
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Internal hook for HLS.js initialization.
|
|
218
|
+
* Handles both native HLS (Safari) and HLS.js (Chrome/Firefox).
|
|
219
|
+
*/
|
|
220
|
+
function useHlsInternal(
|
|
221
|
+
videoRef: React.RefObject<HTMLVideoElement | null>,
|
|
222
|
+
src: string | undefined,
|
|
223
|
+
enabled: boolean,
|
|
224
|
+
) {
|
|
225
|
+
const [isLoading, setIsLoading] = React.useState(true);
|
|
226
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
227
|
+
const hlsRef = React.useRef<unknown>(null);
|
|
228
|
+
|
|
229
|
+
React.useEffect(() => {
|
|
230
|
+
if (!enabled || !src || !videoRef.current) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const video = videoRef.current;
|
|
235
|
+
const isHlsSource = src.includes(".m3u8");
|
|
236
|
+
|
|
237
|
+
// For non-HLS sources, just set the src directly
|
|
238
|
+
if (!isHlsSource) {
|
|
239
|
+
video.src = src;
|
|
240
|
+
setIsLoading(false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check if native HLS is supported (Safari)
|
|
245
|
+
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
|
246
|
+
video.src = src;
|
|
247
|
+
setIsLoading(false);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Try to use HLS.js for other browsers
|
|
252
|
+
const loadHls = async () => {
|
|
253
|
+
try {
|
|
254
|
+
// Dynamic import of HLS.js (peer dependency)
|
|
255
|
+
const HlsModule = await import("hls.js");
|
|
256
|
+
const Hls = HlsModule.default;
|
|
257
|
+
|
|
258
|
+
if (!Hls.isSupported()) {
|
|
259
|
+
// Fallback: try setting src directly
|
|
260
|
+
video.src = src;
|
|
261
|
+
setIsLoading(false);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const hls = new Hls({
|
|
266
|
+
enableWorker: true,
|
|
267
|
+
lowLatencyMode: false,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
hls.loadSource(src);
|
|
271
|
+
hls.attachMedia(video);
|
|
272
|
+
|
|
273
|
+
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
274
|
+
setIsLoading(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
hls.on(
|
|
278
|
+
Hls.Events.ERROR,
|
|
279
|
+
(_event: unknown, data: { fatal?: boolean; details?: string }) => {
|
|
280
|
+
if (data.fatal) {
|
|
281
|
+
setError(new Error(`HLS error: ${data.details}`));
|
|
282
|
+
setIsLoading(false);
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
hlsRef.current = hls;
|
|
288
|
+
} catch {
|
|
289
|
+
// HLS.js not available, try direct source
|
|
290
|
+
video.src = src;
|
|
291
|
+
setIsLoading(false);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
loadHls();
|
|
296
|
+
|
|
297
|
+
return () => {
|
|
298
|
+
if (hlsRef.current) {
|
|
299
|
+
(hlsRef.current as { destroy: () => void }).destroy();
|
|
300
|
+
hlsRef.current = null;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}, [enabled, src, videoRef]);
|
|
304
|
+
|
|
305
|
+
return { isLoading, error };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// VideoPlayer Component
|
|
310
|
+
// ============================================================================
|
|
311
|
+
|
|
312
|
+
export interface VideoPlayerProps
|
|
313
|
+
extends Omit<
|
|
314
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
315
|
+
"children" | "onError" | "onPlay" | "onPause" | "onEnded" | "onTimeUpdate"
|
|
316
|
+
>,
|
|
317
|
+
VariantProps<typeof videoPlayerVariants> {
|
|
318
|
+
/** Video source URL (HLS .m3u8 or regular video file) */
|
|
319
|
+
src?: string;
|
|
320
|
+
/** Cloudflare Stream configuration (takes precedence over src) */
|
|
321
|
+
cloudflare?: CloudflareConfig;
|
|
322
|
+
/** Poster image URL */
|
|
323
|
+
poster?: string;
|
|
324
|
+
/** VTT captions URL */
|
|
325
|
+
captionsSrc?: string;
|
|
326
|
+
/** Whether to autoplay (default: false) */
|
|
327
|
+
autoPlay?: boolean;
|
|
328
|
+
/** Whether to loop the video (default: false) */
|
|
329
|
+
loop?: boolean;
|
|
330
|
+
/** Whether to mute initially (default: false) */
|
|
331
|
+
muted?: boolean;
|
|
332
|
+
/** Whether to show controls (default: true) */
|
|
333
|
+
controls?: boolean;
|
|
334
|
+
/** Whether to auto-hide controls when not interacting (default: true) */
|
|
335
|
+
autoHideControls?: boolean;
|
|
336
|
+
/** Control auto-hide delay in ms (default: 3000) */
|
|
337
|
+
autoHideDelay?: number;
|
|
338
|
+
/** Whether captions are enabled by default (default: false) */
|
|
339
|
+
captionsEnabled?: boolean;
|
|
340
|
+
/** Callback when video starts playing */
|
|
341
|
+
onPlay?: () => void;
|
|
342
|
+
/** Callback when video pauses */
|
|
343
|
+
onPause?: () => void;
|
|
344
|
+
/** Callback when video ends */
|
|
345
|
+
onEnded?: () => void;
|
|
346
|
+
/** Callback on time update */
|
|
347
|
+
onTimeUpdate?: (time: number) => void;
|
|
348
|
+
/** Callback on error */
|
|
349
|
+
onError?: (error: Error) => void;
|
|
350
|
+
/** Ref to the video element */
|
|
351
|
+
videoRef?: React.RefObject<HTMLVideoElement | null>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* VideoPlayer - Standalone video player component with media-chrome controls.
|
|
356
|
+
*
|
|
357
|
+
* Supports Cloudflare Stream (recommended) or direct video URLs with HLS support.
|
|
358
|
+
* Works standalone or can be composed with Modal for fullscreen playback.
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```tsx
|
|
362
|
+
* // With Cloudflare Stream (recommended)
|
|
363
|
+
* <VideoPlayer
|
|
364
|
+
* cloudflare={{ videoId: "abc123", customerCode: "xyz789" }}
|
|
365
|
+
* poster="/thumbnail.jpg"
|
|
366
|
+
* captionsSrc="/captions.vtt"
|
|
367
|
+
* />
|
|
368
|
+
*
|
|
369
|
+
* // With direct URL
|
|
370
|
+
* <VideoPlayer
|
|
371
|
+
* src="https://example.com/video.mp4"
|
|
372
|
+
* poster="/thumbnail.jpg"
|
|
373
|
+
* />
|
|
374
|
+
*
|
|
375
|
+
* // With Modal for fullscreen
|
|
376
|
+
* <Modal trigger={<Button>Watch Video</Button>}>
|
|
377
|
+
* <VideoPlayer cloudflare={{ videoId: "...", customerCode: "..." }} />
|
|
378
|
+
* </Modal>
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
const VideoPlayer = React.forwardRef<HTMLDivElement, VideoPlayerProps>(
|
|
382
|
+
(
|
|
383
|
+
{
|
|
384
|
+
className,
|
|
385
|
+
src,
|
|
386
|
+
cloudflare,
|
|
387
|
+
poster,
|
|
388
|
+
captionsSrc,
|
|
389
|
+
autoPlay = false,
|
|
390
|
+
loop = false,
|
|
391
|
+
muted = false,
|
|
392
|
+
controls = true,
|
|
393
|
+
autoHideControls = true,
|
|
394
|
+
autoHideDelay = 3000,
|
|
395
|
+
captionsEnabled: initialCaptionsEnabled = false,
|
|
396
|
+
aspectRatio,
|
|
397
|
+
rounded,
|
|
398
|
+
onPlay,
|
|
399
|
+
onPause,
|
|
400
|
+
onEnded,
|
|
401
|
+
onTimeUpdate,
|
|
402
|
+
onError,
|
|
403
|
+
videoRef: externalVideoRef,
|
|
404
|
+
...props
|
|
405
|
+
},
|
|
406
|
+
ref,
|
|
407
|
+
) => {
|
|
408
|
+
// Internal refs
|
|
409
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
410
|
+
const internalVideoRef = React.useRef<HTMLVideoElement | null>(null);
|
|
411
|
+
const controlsTimeoutRef = React.useRef<ReturnType<
|
|
412
|
+
typeof setTimeout
|
|
413
|
+
> | null>(null);
|
|
414
|
+
|
|
415
|
+
// State
|
|
416
|
+
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
417
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
418
|
+
const [controlsVisible, setControlsVisible] = React.useState(true);
|
|
419
|
+
const [captionsEnabled, setCaptionsEnabled] = React.useState(
|
|
420
|
+
initialCaptionsEnabled,
|
|
421
|
+
);
|
|
422
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
423
|
+
|
|
424
|
+
// Compute video source URL
|
|
425
|
+
const videoSrc = React.useMemo(() => {
|
|
426
|
+
if (cloudflare) {
|
|
427
|
+
return `https://customer-${cloudflare.customerCode}.cloudflarestream.com/${cloudflare.videoId}/manifest/video.m3u8`;
|
|
428
|
+
}
|
|
429
|
+
return src;
|
|
430
|
+
}, [cloudflare, src]);
|
|
431
|
+
|
|
432
|
+
// HLS support
|
|
433
|
+
const { isLoading, error: hlsError } = useHlsInternal(
|
|
434
|
+
internalVideoRef,
|
|
435
|
+
videoSrc,
|
|
436
|
+
true,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// Caption parsing
|
|
440
|
+
const { activeCue } = useCaptions({
|
|
441
|
+
src: captionsSrc,
|
|
442
|
+
currentTime,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Merge refs
|
|
446
|
+
React.useEffect(() => {
|
|
447
|
+
if (externalVideoRef) {
|
|
448
|
+
(
|
|
449
|
+
externalVideoRef as React.MutableRefObject<HTMLVideoElement | null>
|
|
450
|
+
).current = internalVideoRef.current;
|
|
451
|
+
}
|
|
452
|
+
}, [externalVideoRef]);
|
|
453
|
+
|
|
454
|
+
// Merge container ref
|
|
455
|
+
React.useImperativeHandle(
|
|
456
|
+
ref,
|
|
457
|
+
() => containerRef.current as HTMLDivElement,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Report errors
|
|
461
|
+
React.useEffect(() => {
|
|
462
|
+
if (hlsError && onError) {
|
|
463
|
+
onError(hlsError);
|
|
464
|
+
}
|
|
465
|
+
}, [hlsError, onError]);
|
|
466
|
+
|
|
467
|
+
// Video event handlers
|
|
468
|
+
React.useEffect(() => {
|
|
469
|
+
const video = internalVideoRef.current;
|
|
470
|
+
if (!video) return;
|
|
471
|
+
|
|
472
|
+
const handlePlay = () => {
|
|
473
|
+
setIsPlaying(true);
|
|
474
|
+
onPlay?.();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const handlePause = () => {
|
|
478
|
+
setIsPlaying(false);
|
|
479
|
+
onPause?.();
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const handleEnded = () => {
|
|
483
|
+
setIsPlaying(false);
|
|
484
|
+
onEnded?.();
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const handleTimeUpdate = () => {
|
|
488
|
+
setCurrentTime(video.currentTime);
|
|
489
|
+
onTimeUpdate?.(video.currentTime);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const handleCanPlay = () => {
|
|
493
|
+
if (autoPlay) {
|
|
494
|
+
video.play().catch(() => {
|
|
495
|
+
// Autoplay may be blocked
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
video.addEventListener("play", handlePlay);
|
|
501
|
+
video.addEventListener("pause", handlePause);
|
|
502
|
+
video.addEventListener("ended", handleEnded);
|
|
503
|
+
video.addEventListener("timeupdate", handleTimeUpdate);
|
|
504
|
+
video.addEventListener("canplay", handleCanPlay);
|
|
505
|
+
|
|
506
|
+
return () => {
|
|
507
|
+
video.removeEventListener("play", handlePlay);
|
|
508
|
+
video.removeEventListener("pause", handlePause);
|
|
509
|
+
video.removeEventListener("ended", handleEnded);
|
|
510
|
+
video.removeEventListener("timeupdate", handleTimeUpdate);
|
|
511
|
+
video.removeEventListener("canplay", handleCanPlay);
|
|
512
|
+
};
|
|
513
|
+
}, [autoPlay, onPlay, onPause, onEnded, onTimeUpdate]);
|
|
514
|
+
|
|
515
|
+
// Auto-hide controls
|
|
516
|
+
React.useEffect(() => {
|
|
517
|
+
if (!autoHideControls || !isPlaying || !controlsVisible) return;
|
|
518
|
+
|
|
519
|
+
controlsTimeoutRef.current = setTimeout(() => {
|
|
520
|
+
setControlsVisible(false);
|
|
521
|
+
}, autoHideDelay);
|
|
522
|
+
|
|
523
|
+
return () => {
|
|
524
|
+
if (controlsTimeoutRef.current) {
|
|
525
|
+
clearTimeout(controlsTimeoutRef.current);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}, [autoHideControls, isPlaying, controlsVisible, autoHideDelay]);
|
|
529
|
+
|
|
530
|
+
// Track fullscreen state
|
|
531
|
+
React.useEffect(() => {
|
|
532
|
+
const handleFullscreenChange = () => {
|
|
533
|
+
setIsFullscreen(!!document.fullscreenElement);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
537
|
+
document.addEventListener(
|
|
538
|
+
"webkitfullscreenchange",
|
|
539
|
+
handleFullscreenChange,
|
|
540
|
+
);
|
|
541
|
+
return () => {
|
|
542
|
+
document.removeEventListener(
|
|
543
|
+
"fullscreenchange",
|
|
544
|
+
handleFullscreenChange,
|
|
545
|
+
);
|
|
546
|
+
document.removeEventListener(
|
|
547
|
+
"webkitfullscreenchange",
|
|
548
|
+
handleFullscreenChange,
|
|
549
|
+
);
|
|
550
|
+
};
|
|
551
|
+
}, []);
|
|
552
|
+
|
|
553
|
+
// Actions
|
|
554
|
+
const togglePlay = React.useCallback(() => {
|
|
555
|
+
const video = internalVideoRef.current;
|
|
556
|
+
if (!video) return;
|
|
557
|
+
|
|
558
|
+
if (video.paused) {
|
|
559
|
+
video.play().catch(() => {});
|
|
560
|
+
} else {
|
|
561
|
+
video.pause();
|
|
562
|
+
}
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
const toggleCaptions = React.useCallback(() => {
|
|
566
|
+
setCaptionsEnabled((prev) => !prev);
|
|
567
|
+
}, []);
|
|
568
|
+
|
|
569
|
+
const toggleFullscreen = React.useCallback(() => {
|
|
570
|
+
if (!document.fullscreenElement) {
|
|
571
|
+
containerRef.current?.requestFullscreen();
|
|
572
|
+
} else {
|
|
573
|
+
document.exitFullscreen();
|
|
574
|
+
}
|
|
575
|
+
}, []);
|
|
576
|
+
|
|
577
|
+
const showControls = React.useCallback(() => {
|
|
578
|
+
setControlsVisible(true);
|
|
579
|
+
if (controlsTimeoutRef.current) {
|
|
580
|
+
clearTimeout(controlsTimeoutRef.current);
|
|
581
|
+
}
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
const handleMouseLeave = React.useCallback(() => {
|
|
585
|
+
if (autoHideControls && isPlaying) {
|
|
586
|
+
setControlsVisible(false);
|
|
587
|
+
}
|
|
588
|
+
}, [autoHideControls, isPlaying]);
|
|
589
|
+
|
|
590
|
+
// Keyboard shortcuts for video player
|
|
591
|
+
const { containerProps: keyboardProps } = useVideoKeyboard({
|
|
592
|
+
videoRef: internalVideoRef,
|
|
593
|
+
onTogglePlay: togglePlay,
|
|
594
|
+
onToggleFullscreen: toggleFullscreen,
|
|
595
|
+
onToggleCaptions: captionsSrc ? toggleCaptions : undefined,
|
|
596
|
+
onShowControls: showControls,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return (
|
|
600
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: role is applied via keyboardProps spread
|
|
601
|
+
<div
|
|
602
|
+
ref={containerRef}
|
|
603
|
+
className={cn(videoPlayerVariants({ aspectRatio, rounded }), className)}
|
|
604
|
+
onMouseMove={showControls}
|
|
605
|
+
onMouseLeave={handleMouseLeave}
|
|
606
|
+
{...keyboardProps}
|
|
607
|
+
{...props}
|
|
608
|
+
>
|
|
609
|
+
{controls ? (
|
|
610
|
+
<MediaController
|
|
611
|
+
noAutohide={true}
|
|
612
|
+
className={mediaControllerVariants()}
|
|
613
|
+
>
|
|
614
|
+
{/* Video Element */}
|
|
615
|
+
<video
|
|
616
|
+
ref={internalVideoRef}
|
|
617
|
+
slot="media"
|
|
618
|
+
poster={poster}
|
|
619
|
+
loop={loop}
|
|
620
|
+
muted={muted}
|
|
621
|
+
playsInline
|
|
622
|
+
crossOrigin="anonymous"
|
|
623
|
+
className="w-full h-full object-contain"
|
|
624
|
+
/>
|
|
625
|
+
|
|
626
|
+
{/* Loading Indicator */}
|
|
627
|
+
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
|
628
|
+
|
|
629
|
+
{/* Click to play/pause overlay */}
|
|
630
|
+
<div
|
|
631
|
+
onClick={togglePlay}
|
|
632
|
+
className="absolute inset-0 cursor-pointer z-[1]"
|
|
633
|
+
aria-hidden="true"
|
|
634
|
+
/>
|
|
635
|
+
|
|
636
|
+
{/* Control Bar */}
|
|
637
|
+
<MediaControlBar
|
|
638
|
+
className={controlBarVariants({ visible: controlsVisible })}
|
|
639
|
+
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
640
|
+
style={{
|
|
641
|
+
position: "absolute",
|
|
642
|
+
left: "24px",
|
|
643
|
+
right: "24px",
|
|
644
|
+
bottom: "24px",
|
|
645
|
+
gap: "12px",
|
|
646
|
+
padding: "8px 16px",
|
|
647
|
+
borderRadius: "9999px",
|
|
648
|
+
backdropFilter: "blur(10px)",
|
|
649
|
+
WebkitBackdropFilter: "blur(10px)",
|
|
650
|
+
zIndex: 2,
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
<MediaPlayButton style={mediaButtonStyles} />
|
|
654
|
+
<MediaMuteButton style={mediaButtonStyles} />
|
|
655
|
+
<MediaVolumeRange style={volumeRangeStyles} />
|
|
656
|
+
<MediaTimeDisplay
|
|
657
|
+
style={timeDisplayStyles}
|
|
658
|
+
showDuration
|
|
659
|
+
noToggle
|
|
660
|
+
/>
|
|
661
|
+
<MediaTimeRange style={timeRangeStyles} />
|
|
662
|
+
|
|
663
|
+
{/* Captions Button */}
|
|
664
|
+
{captionsSrc && (
|
|
665
|
+
<button
|
|
666
|
+
type="button"
|
|
667
|
+
className={controlButtonVariants()}
|
|
668
|
+
onClick={(e) => {
|
|
669
|
+
e.stopPropagation();
|
|
670
|
+
toggleCaptions();
|
|
671
|
+
}}
|
|
672
|
+
aria-label={
|
|
673
|
+
captionsEnabled ? "Disable captions" : "Enable captions"
|
|
674
|
+
}
|
|
675
|
+
aria-pressed={captionsEnabled}
|
|
676
|
+
>
|
|
677
|
+
<CaptionsIcon enabled={captionsEnabled} />
|
|
678
|
+
</button>
|
|
679
|
+
)}
|
|
680
|
+
|
|
681
|
+
{/* Fullscreen Button */}
|
|
682
|
+
<button
|
|
683
|
+
type="button"
|
|
684
|
+
className={controlButtonVariants()}
|
|
685
|
+
onClick={(e) => {
|
|
686
|
+
e.stopPropagation();
|
|
687
|
+
toggleFullscreen();
|
|
688
|
+
}}
|
|
689
|
+
aria-label={
|
|
690
|
+
isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
|
|
691
|
+
}
|
|
692
|
+
>
|
|
693
|
+
<FullscreenIcon isFullscreen={isFullscreen} />
|
|
694
|
+
</button>
|
|
695
|
+
</MediaControlBar>
|
|
696
|
+
</MediaController>
|
|
697
|
+
) : (
|
|
698
|
+
/* Video without controls */
|
|
699
|
+
<video
|
|
700
|
+
ref={internalVideoRef}
|
|
701
|
+
poster={poster}
|
|
702
|
+
loop={loop}
|
|
703
|
+
muted={muted}
|
|
704
|
+
playsInline
|
|
705
|
+
crossOrigin="anonymous"
|
|
706
|
+
className="w-full h-full object-contain"
|
|
707
|
+
onClick={togglePlay}
|
|
708
|
+
/>
|
|
709
|
+
)}
|
|
710
|
+
|
|
711
|
+
{/* Loading Overlay (when HLS is loading) */}
|
|
712
|
+
{isLoading && (
|
|
713
|
+
<div className={loadingOverlayVariants()}>
|
|
714
|
+
<div className="w-40 h-40 border-3 border-white/30 border-t-white rounded-full animate-spin" />
|
|
715
|
+
</div>
|
|
716
|
+
)}
|
|
717
|
+
|
|
718
|
+
{/* Error Display */}
|
|
719
|
+
{hlsError && (
|
|
720
|
+
<div className={loadingOverlayVariants()}>
|
|
721
|
+
<div className="text-white text-center px-16">
|
|
722
|
+
<p className="typography-body-sm-sm">Failed to load video</p>
|
|
723
|
+
<p className="typography-caption text-white/60 mt-4">
|
|
724
|
+
{hlsError.message}
|
|
725
|
+
</p>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
)}
|
|
729
|
+
|
|
730
|
+
{/* Caption Overlay */}
|
|
731
|
+
{captionsEnabled && activeCue && <CaptionOverlay cue={activeCue} />}
|
|
732
|
+
</div>
|
|
733
|
+
);
|
|
734
|
+
},
|
|
735
|
+
);
|
|
736
|
+
VideoPlayer.displayName = "VideoPlayer";
|
|
737
|
+
|
|
738
|
+
// ============================================================================
|
|
739
|
+
// Icons
|
|
740
|
+
// ============================================================================
|
|
741
|
+
|
|
742
|
+
const CaptionsIcon = ({ enabled }: { enabled: boolean }) => (
|
|
743
|
+
<svg
|
|
744
|
+
className="w-20 h-20"
|
|
745
|
+
viewBox="0 0 24 24"
|
|
746
|
+
fill="currentColor"
|
|
747
|
+
aria-hidden="true"
|
|
748
|
+
>
|
|
749
|
+
{enabled ? (
|
|
750
|
+
// Captions On
|
|
751
|
+
<path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z" />
|
|
752
|
+
) : (
|
|
753
|
+
// Captions Off (with strike-through)
|
|
754
|
+
<>
|
|
755
|
+
<path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z" />
|
|
756
|
+
<line
|
|
757
|
+
x1="4"
|
|
758
|
+
y1="20"
|
|
759
|
+
x2="20"
|
|
760
|
+
y2="4"
|
|
761
|
+
stroke="currentColor"
|
|
762
|
+
strokeWidth="2"
|
|
763
|
+
/>
|
|
764
|
+
</>
|
|
765
|
+
)}
|
|
766
|
+
</svg>
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const FullscreenIcon = ({ isFullscreen }: { isFullscreen: boolean }) => (
|
|
770
|
+
<svg
|
|
771
|
+
className="w-20 h-20"
|
|
772
|
+
viewBox="0 0 24 24"
|
|
773
|
+
fill="none"
|
|
774
|
+
stroke="currentColor"
|
|
775
|
+
strokeWidth="2"
|
|
776
|
+
strokeLinecap="round"
|
|
777
|
+
strokeLinejoin="round"
|
|
778
|
+
aria-hidden="true"
|
|
779
|
+
>
|
|
780
|
+
{isFullscreen ? (
|
|
781
|
+
// Minimize (exit fullscreen)
|
|
782
|
+
<>
|
|
783
|
+
<polyline points="4 14 10 14 10 20" />
|
|
784
|
+
<polyline points="20 10 14 10 14 4" />
|
|
785
|
+
<line x1="14" y1="10" x2="21" y2="3" />
|
|
786
|
+
<line x1="3" y1="21" x2="10" y2="14" />
|
|
787
|
+
</>
|
|
788
|
+
) : (
|
|
789
|
+
// Maximize (enter fullscreen)
|
|
790
|
+
<>
|
|
791
|
+
<polyline points="15 3 21 3 21 9" />
|
|
792
|
+
<polyline points="9 21 3 21 3 15" />
|
|
793
|
+
<line x1="21" y1="3" x2="14" y2="10" />
|
|
794
|
+
<line x1="3" y1="21" x2="10" y2="14" />
|
|
795
|
+
</>
|
|
796
|
+
)}
|
|
797
|
+
</svg>
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
// ============================================================================
|
|
801
|
+
// Exports
|
|
802
|
+
// ============================================================================
|
|
803
|
+
|
|
804
|
+
export {
|
|
805
|
+
VideoPlayer,
|
|
806
|
+
videoPlayerVariants,
|
|
807
|
+
mediaControllerVariants,
|
|
808
|
+
controlBarVariants,
|
|
809
|
+
controlButtonVariants,
|
|
810
|
+
loadingOverlayVariants,
|
|
811
|
+
};
|