@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.
@@ -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
+ };