@flamingo-stack/openframe-frontend-core 0.0.177 → 0.0.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{chunk-6LDN3CIY.js → chunk-AAX27BCR.js} +189 -348
  2. package/dist/chunk-AAX27BCR.js.map +1 -0
  3. package/dist/{chunk-WX7PT5C7.cjs → chunk-ALW3D72O.cjs} +61 -2
  4. package/dist/chunk-ALW3D72O.cjs.map +1 -0
  5. package/dist/{chunk-KB2N44BY.js → chunk-FMWHOUFE.js} +61 -2
  6. package/dist/chunk-FMWHOUFE.js.map +1 -0
  7. package/dist/{chunk-C6ZMI4UB.cjs → chunk-L4T24AN4.cjs} +113 -272
  8. package/dist/chunk-L4T24AN4.cjs.map +1 -0
  9. package/dist/components/features/index.cjs +3 -5
  10. package/dist/components/features/index.cjs.map +1 -1
  11. package/dist/components/features/index.js +2 -4
  12. package/dist/components/features/video-player.d.ts +17 -20
  13. package/dist/components/features/video-player.d.ts.map +1 -1
  14. package/dist/components/features/youtube-embed.d.ts +18 -4
  15. package/dist/components/features/youtube-embed.d.ts.map +1 -1
  16. package/dist/components/index.cjs +3 -5
  17. package/dist/components/index.cjs.map +1 -1
  18. package/dist/components/index.js +2 -4
  19. package/dist/components/navigation/index.cjs +3 -3
  20. package/dist/components/navigation/index.js +2 -2
  21. package/dist/components/ui/index.cjs +3 -3
  22. package/dist/components/ui/index.js +2 -2
  23. package/dist/hooks/index.cjs +4 -2
  24. package/dist/hooks/index.cjs.map +1 -1
  25. package/dist/hooks/index.d.ts +1 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/index.js +3 -1
  28. package/dist/hooks/use-near-viewport.d.ts +42 -0
  29. package/dist/hooks/use-near-viewport.d.ts.map +1 -0
  30. package/dist/index.cjs +3 -3
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.js +4 -4
  33. package/package.json +1 -1
  34. package/src/components/features/video-player.tsx +39 -176
  35. package/src/components/features/youtube-embed.tsx +107 -224
  36. package/src/hooks/index.ts +3 -0
  37. package/src/hooks/use-near-viewport.ts +118 -0
  38. package/dist/chunk-6LDN3CIY.js.map +0 -1
  39. package/dist/chunk-C6ZMI4UB.cjs.map +0 -1
  40. package/dist/chunk-KB2N44BY.js.map +0 -1
  41. package/dist/chunk-WX7PT5C7.cjs.map +0 -1
  42. package/src/components/features/__tests__/video-player.test.tsx +0 -142
@@ -18,112 +18,6 @@ import { AlertCircleIcon } from '../icons-v2-generated/interface';
18
18
  import { Button } from '../ui/button';
19
19
  import { Input } from '../ui/input';
20
20
 
21
- /**
22
- * Extract a first-frame poster from a direct video file URL via an
23
- * off-DOM `<video>` + `<canvas>`. This is the most reliable way to get
24
- * a real thumbnail for ANY video regardless of encoding or moov atom
25
- * placement (which is what breaks the `#t=0.5` URL-hash trick on long
26
- * videos whose moov atom is written at the end of the file).
27
- *
28
- * Sequence:
29
- * 1. Create hidden `<video>` with `crossOrigin="anonymous"` (REQUIRED
30
- * before setting src, otherwise the canvas is tainted).
31
- * 2. `loadedmetadata` → seek to 10% of duration (clamped 2s–30s).
32
- * 3. `seeked` → draw the frame to a canvas → export as data URL.
33
- * 4. Clean up the hidden video immediately.
34
- *
35
- * Falls back to `null` on CORS failure, decode error, or unsupported
36
- * video format. Callers should treat `null` as "no poster — show play
37
- * overlay without a thumbnail."
38
- */
39
- function useVideoFirstFramePoster(
40
- url: string | undefined,
41
- enabled: boolean,
42
- ): string | null {
43
- const [poster, setPoster] = useState<string | null>(null);
44
-
45
- useEffect(() => {
46
- if (!enabled || !url) {
47
- setPoster(null);
48
- return;
49
- }
50
- // Only direct video files — skip YouTube, Vimeo, HLS, etc.
51
- const isDirectFile = /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
52
- if (!isDirectFile) {
53
- setPoster(null);
54
- return;
55
- }
56
-
57
- let cancelled = false;
58
- const video = document.createElement('video');
59
- video.crossOrigin = 'anonymous';
60
- video.preload = 'metadata';
61
- video.muted = true;
62
- video.playsInline = true;
63
- // Tags this hidden element so regression tests can detect the poster-extractor
64
- // path being entered without false-positives from any other code that might
65
- // happen to set crossOrigin on a <video>. See video-player.test.tsx.
66
- video.setAttribute('data-poster-extractor', 'true');
67
-
68
- const cleanup = () => {
69
- video.removeAttribute('src');
70
- try { video.load(); } catch { /* noop */ }
71
- };
72
-
73
- const onLoadedMetadata = () => {
74
- if (cancelled) return;
75
- // Seek to 10% of the video's duration (clamped between 2s and 30s).
76
- // Many videos have a dark fade-in during the first 1-2 seconds;
77
- // 10% is far enough to land on real content for both short clips
78
- // (30s → 3s) and long recordings (30min → 3min, clamped to 30s).
79
- const dur = video.duration || 10;
80
- const target = Math.max(2, Math.min(dur * 0.1, 30));
81
- video.currentTime = target;
82
- };
83
-
84
- const onSeeked = () => {
85
- if (cancelled) return;
86
- try {
87
- const canvas = document.createElement('canvas');
88
- canvas.width = video.videoWidth;
89
- canvas.height = video.videoHeight;
90
- const ctx = canvas.getContext('2d');
91
- if (!ctx || !canvas.width || !canvas.height) {
92
- cleanup();
93
- return;
94
- }
95
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
96
- const dataUrl = canvas.toDataURL('image/jpeg', 0.82);
97
- if (!cancelled) setPoster(dataUrl);
98
- } catch (err) {
99
- // Tainted canvas (CORS) or decode failure — silently give up.
100
- console.warn('[VideoPlayer] first-frame extraction failed', err);
101
- } finally {
102
- cleanup();
103
- }
104
- };
105
-
106
- const onError = () => {
107
- cleanup();
108
- };
109
-
110
- video.addEventListener('loadedmetadata', onLoadedMetadata);
111
- video.addEventListener('seeked', onSeeked);
112
- video.addEventListener('error', onError);
113
- video.src = url;
114
-
115
- return () => {
116
- cancelled = true;
117
- video.removeEventListener('loadedmetadata', onLoadedMetadata);
118
- video.removeEventListener('seeked', onSeeked);
119
- video.removeEventListener('error', onError);
120
- cleanup();
121
- };
122
- }, [url, enabled]);
123
-
124
- return poster;
125
- }
126
-
127
21
  /**
128
22
  * Global CSS injection for iOS Safari native fullscreen captions.
129
23
  * Safari hides ::-webkit-media-text-track-container in fullscreen by default.
@@ -237,45 +131,32 @@ interface VideoPlayerProps {
237
131
  /** Label for the subtitle track (default: 'English') */
238
132
  subtitleLabel?: string;
239
133
  /**
240
- * When true, defers all network activity for the video until the user clicks play:
241
- * - Skips the hidden-video first-frame poster extraction (no canvas dance)
242
- * - Sets `preload="none"` on the underlying `<video>` (zero bytes fetched on mount)
243
- * - On play-click, switches `preload="auto"` and synchronously calls `videoEl.play()`
244
- * inside the click handler to preserve the iOS Safari user-activation chain
134
+ * Controls how aggressively the browser preloads the video before the user
135
+ * presses play. Mirrors the underlying `<video preload>` attribute.
245
136
  *
246
- * Use for testimonial/marketing videos where:
247
- * - The video may never be played (most visitors don't click)
248
- * - A real poster image is available (`poster` prop) so the user has something to look at
249
- * - You want to eliminate the redundant hidden-video metadata fetch that races
250
- * ReactPlayer's own metadata fetch
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.
251
145
  *
252
- * Interaction semantics:
253
- * - `lazyMount + autoPlay``autoPlay` wins; lazyMount is ignored (caller has explicitly committed)
254
- * - `lazyMount + loop` orthogonal; loop applies after first play
255
- * - `lazyMount + useNativeAspectRatio` orthogonal; native aspect comes from the existing `<video>`
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.
256
151
  *
257
- * Behavior change for non-lazy callers (lazyMount=false): once `hasStarted=true`, preload
258
- * dynamically promotes from `'metadata'` to `'auto'` for aggressive buffering. Browser
259
- * dedupes ranged requests on the same resource so this is benign in practice.
260
- *
261
- * @default false
152
+ * @default 'metadata'
262
153
  */
263
- lazyMount?: boolean;
154
+ preloadStrategy?: 'auto' | 'metadata' | 'none';
264
155
  }
265
156
 
266
157
  /** Playback speed options */
267
158
  const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
268
159
 
269
- /**
270
- * Grace period after a lazyMount click before we surface a play() failure to
271
- * the user via the error UI. Calibrated for the typical case where a slow
272
- * Supabase Storage origin takes 1-2s to first-byte, then ReactPlayer's React
273
- * state-driven .play() retry takes another ~500ms. Anything still paused
274
- * AND carrying an `HTMLMediaElement.error` after this window is genuinely
275
- * broken (codec mismatch, 404, CORS), not just slow.
276
- */
277
- const LAZY_MOUNT_PLAY_FAILURE_GRACE_MS = 2000;
278
-
279
160
  /** Format seconds to M:SS or H:MM:SS display */
280
161
  function formatTime(secs: number): string {
281
162
  if (!secs || !isFinite(secs)) return '0:00';
@@ -299,7 +180,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
299
180
  srtContent,
300
181
  captionsUrl,
301
182
  subtitleLabel,
302
- lazyMount = false,
183
+ preloadStrategy = 'metadata',
303
184
  }) => {
304
185
  // =========================================================================
305
186
  // Core state
@@ -323,10 +204,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
323
204
  const hideTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
324
205
  const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
325
206
  const iosFullscreenTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
326
- // Tracks the iOS lazyMount play() failure-escalation timer so unmount can
327
- // cancel it. Without this, a click-then-navigate within the grace window
328
- // would fire `setHasError` on a torn-down component (React 18 warns).
329
- const lazyMountFailureTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
330
207
 
331
208
  // Subtitle + fullscreen state
332
209
  const [captionsEnabled, setCaptionsEnabled] = useState(true);
@@ -666,10 +543,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
666
543
  handleTouchToggle();
667
544
  }, [hasStarted, handleTouchToggle]);
668
545
 
669
- // Extract first frame as poster when no explicit poster provided.
670
- // Skipped entirely in lazyMount mode (no hidden-video element, no canvas dance).
671
- const extractedPoster = useVideoFirstFramePoster(url, !lazyMount && !hasStarted && !poster);
672
- const effectivePoster = poster || extractedPoster || undefined;
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;
673
551
  const posterBgColor = useImageEdgeColor(effectivePoster);
674
552
 
675
553
  useEffect(() => {
@@ -679,7 +557,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
679
557
  clearTimeout(clickTimerRef.current);
680
558
  clearTimeout(hideTimeoutRef.current);
681
559
  clearTimeout(iosFullscreenTimerRef.current);
682
- clearTimeout(lazyMountFailureTimerRef.current);
683
560
  isDraggingRef.current = false;
684
561
  if (dragListenersRef.current) {
685
562
  document.removeEventListener('mousemove', dragListenersRef.current.move);
@@ -707,39 +584,25 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
707
584
  const handlePause = useCallback(() => setIsPlaying(false), []);
708
585
  const handleEnded = useCallback(() => setIsPlaying(false), []);
709
586
  const handlePlayClick = useCallback(() => {
710
- // iOS user-activation belt-and-suspenders: when lazyMount is on, the React state
711
- // updates below cause a re-render that promotes preload to 'auto' and triggers
712
- // ReactPlayer's internal play() but that play() call may run in a separate task
713
- // from the click event, which iOS Safari treats as non-user-initiated.
714
- // Calling .play() synchronously here preserves the user-activation flag.
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.
715
594
  //
716
- // Guards:
717
- // - `instanceof HTMLVideoElement` `getInternalPlayer()` returns YouTube/Vimeo
718
- // iframe wrappers when those URL types are passed; we only want the native
719
- // <video> element for the file player path.
720
- // - `.catch` escalates persistent failures to the error UI after a grace
721
- // period so users don't stare at a frozen poster forever (HEVC-in-MP4 on
722
- // Chrome/Firefox is the common cause; the admin form warns about this).
723
- // - Failure timer is stored in a ref so unmount can cancel it (otherwise
724
- // a click-then-navigate within the grace window would fire setHasError
725
- // on a torn-down component).
726
- if (lazyMount) {
727
- const native = playerRef.current?.getInternalPlayer();
728
- if (native instanceof HTMLVideoElement) {
729
- native.play().catch(() => {
730
- // Allow React's state-driven play() one chance, then surface the failure.
731
- clearTimeout(lazyMountFailureTimerRef.current);
732
- lazyMountFailureTimerRef.current = setTimeout(() => {
733
- if (native.paused && native.error) setHasError(true);
734
- }, LAZY_MOUNT_PLAY_FAILURE_GRACE_MS);
735
- });
736
- } else if (process.env.NODE_ENV !== 'production') {
737
- console.warn('[VideoPlayer] lazyMount sync play(): no native HTMLVideoElement yet');
738
- }
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');
739
602
  }
740
603
  setHasStarted(true);
741
604
  setIsPlaying(true);
742
- }, [lazyMount]);
605
+ }, []);
743
606
 
744
607
  const handleProgress = useCallback(({ played: p, loaded: l, playedSeconds }: { played: number; loaded: number; playedSeconds: number }) => {
745
608
  setPlayed(p);
@@ -859,7 +722,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
859
722
  onBufferEnd={handleBufferEnd}
860
723
  onProgress={handleProgress}
861
724
  progressInterval={200}
862
- config={{ file: { attributes: { controlsList: 'nodownload', playsInline: true, preload: lazyMount && !hasStarted ? 'none' : (hasStarted ? 'auto' : 'metadata') } } }}
725
+ config={{ file: { attributes: { controlsList: 'nodownload', playsInline: true, preload: hasStarted ? 'auto' : preloadStrategy } } }}
863
726
  light={false}
864
727
  playsinline
865
728
  />
@@ -1,27 +1,17 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useEffect } from 'react';
4
- import ReactPlayer from 'react-player/youtube';
5
-
6
- // Simple SVG icon components
7
- const Play = ({ size = 16, className }: { size?: number; className?: string }) => (
8
- <svg width={size} height={size} fill="currentColor" viewBox="0 0 24 24" className={className}>
9
- <polygon points="5,3 19,12 5,21"/>
10
- </svg>
11
- );
12
-
13
- const Loader = ({ size = 16, className }: { size?: number; className?: string }) => (
14
- <svg width={size} height={size} fill="none" stroke="currentColor" viewBox="0 0 24 24" className={className}>
15
- <line x1="12" y1="2" x2="12" y2="6"/>
16
- <line x1="12" y1="18" x2="12" y2="22"/>
17
- <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/>
18
- <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
19
- <line x1="2" y1="12" x2="6" y2="12"/>
20
- <line x1="18" y1="12" x2="22" y2="12"/>
21
- <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/>
22
- <line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
23
- </svg>
24
- );
3
+ import React, { useRef, useState } from 'react';
4
+
5
+ /**
6
+ * YouTube facade (lite-youtube-embed pattern). Renders poster + play
7
+ * button until clicked; on click, a real `<iframe>` is created via
8
+ * `document.createElement` synchronously so the user-activation chain
9
+ * holds and `autoplay=1` plays on Chrome / Safari / Firefox.
10
+ *
11
+ * Uses `youtube-nocookie.com` (GDPR-friendly) and `mqdefault` posters
12
+ * (`maxresdefault` returns a gray 200 placeholder when missing, which
13
+ * defeats `onError` fallback).
14
+ */
25
15
 
26
16
  interface YouTubeEmbedProps {
27
17
  videoId: string;
@@ -30,198 +20,99 @@ interface YouTubeEmbedProps {
30
20
  showTitle?: boolean;
31
21
  showMeta?: boolean;
32
22
  minimalControls?: boolean;
23
+ /** When true, the poster img gets `fetchpriority="high"` for LCP. Default false (below-the-fold). */
24
+ aboveTheFold?: boolean;
33
25
  }
34
26
 
35
- export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
36
- videoId,
37
- title = "YouTube Video",
38
- className = "",
27
+ export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
28
+ videoId,
29
+ title = 'YouTube Video',
30
+ className = '',
39
31
  showTitle = true,
40
32
  showMeta = true,
41
- minimalControls = false
33
+ minimalControls = false,
34
+ aboveTheFold = false,
42
35
  }) => {
43
- const [isLoading, setIsLoading] = useState(true);
44
- const [hasError, setHasError] = useState(false);
45
- const [isPlaying, setIsPlaying] = useState(false);
46
- const [useIframe, setUseIframe] = useState(false);
47
- const [mounted, setMounted] = useState(false);
48
-
49
- useEffect(() => {
50
- setMounted(true);
51
-
52
- // Always use iframe to avoid CSP issues with ReactPlayer scripts
53
- setUseIframe(true);
54
- setIsLoading(false);
55
- }, []);
56
-
57
- const handleReady = () => {
58
- setIsLoading(false);
59
- };
36
+ const [activated, setActivated] = useState(false);
37
+ const iframeSlotRef = useRef<HTMLDivElement | null>(null);
60
38
 
61
- const handleError = () => {
62
- setIsLoading(false);
63
- setHasError(true);
64
- // Fallback to iframe on error
65
- setUseIframe(true);
66
- };
67
-
68
- const handlePlay = () => {
69
- setIsPlaying(true);
70
- };
71
-
72
- const handlePause = () => {
73
- setIsPlaying(false);
74
- };
75
-
76
- const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
77
-
78
- // Build embed URL with conditional parameters
79
- const embedParams = new URLSearchParams({
80
- rel: '0',
81
- modestbranding: '1',
82
- playsinline: '1'
83
- });
84
-
39
+ const embedParams = new URLSearchParams({ autoplay: '1', rel: '0', modestbranding: '1', playsinline: '1' });
85
40
  if (minimalControls) {
86
- embedParams.set('controls', '0'); // Hide all controls
87
- embedParams.set('showinfo', '0'); // Hide video info
88
- embedParams.set('fs', '0'); // Disable fullscreen
89
- embedParams.set('iv_load_policy', '3'); // Hide annotations
90
- embedParams.set('cc_load_policy', '0'); // Hide captions
91
- embedParams.set('disablekb', '1'); // Disable keyboard controls
92
- embedParams.set('rel', '0'); // Hide related videos (already set above, but ensure it's enforced)
93
- }
94
-
95
- const embedUrl = `https://www.youtube.com/embed/${videoId}?${embedParams.toString()}`;
96
-
97
- // Don't render until mounted to prevent hydration mismatch
98
- if (!mounted) {
99
- return (
100
- <div className={`youtube-embed-container my-6 ${className}`}>
101
- <div className="video-wrapper relative w-full" style={{ paddingBottom: '56.25%' }}>
102
- <div className="loading-overlay absolute inset-0 bg-ods-card border border-ods-border rounded-lg flex items-center justify-center">
103
- <div className="loading-content flex flex-col items-center gap-3">
104
- <Loader className="animate-spin text-ods-accent" size={32} />
105
- <span className="font-sans text-sm text-ods-text-secondary">Loading video...</span>
106
- </div>
107
- </div>
108
- </div>
109
- </div>
110
- );
111
- }
112
-
113
- if (hasError) {
114
- return (
115
- <div className={`youtube-embed-error ${className}`}>
116
- <div className="error-state bg-ods-card border border-ods-error rounded-lg p-6 text-center my-6">
117
- <div className="error-icon flex justify-center mb-4">
118
- <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24" className="text-ods-error">
119
- <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
120
- </svg>
121
- </div>
122
- <div className="error-title font-sans font-semibold text-lg text-ods-error mb-2">
123
- Video Unavailable
124
- </div>
125
- <div className="error-description font-sans text-sm text-ods-text-secondary mb-4">
126
- Unable to load YouTube video. The video may be private or deleted.
127
- </div>
128
- <a
129
- href={videoUrl}
130
- target="_blank"
131
- rel="noopener noreferrer"
132
- className="error-retry-button bg-ods-error hover:bg-ods-error-hover text-ods-text-on-error border-none rounded px-4 py-2 font-sans font-medium text-sm cursor-pointer transition-colors duration-200"
133
- >
134
- Watch on YouTube
135
- </a>
136
- </div>
137
- </div>
138
- );
41
+ embedParams.set('controls', '0');
42
+ embedParams.set('showinfo', '0');
43
+ embedParams.set('fs', '0');
44
+ embedParams.set('iv_load_policy', '3');
45
+ embedParams.set('cc_load_policy', '0');
46
+ embedParams.set('disablekb', '1');
139
47
  }
48
+ const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?${embedParams.toString()}`;
49
+ const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
50
+ const posterJpg = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
51
+ const posterWebp = `https://i.ytimg.com/vi_webp/${videoId}/mqdefault.webp`;
52
+
53
+ // `iframeSlotRef` is a JSX-empty div React owns but never reconciles
54
+ // children into; the `<button>` overlay is a SIBLING of that slot, not
55
+ // a child. Without that split, React would yank the button on
56
+ // re-render and trip `removeChild ... is not a child of this node`.
57
+ const handleActivate = () => {
58
+ const slot = iframeSlotRef.current;
59
+ if (!slot || activated) return;
60
+ const iframe = document.createElement('iframe');
61
+ iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
62
+ iframe.setAttribute('allowfullscreen', '');
63
+ iframe.setAttribute('title', title);
64
+ iframe.className = 'absolute inset-0 w-full h-full border-0';
65
+ iframe.src = embedUrl;
66
+ slot.appendChild(iframe);
67
+ setActivated(true);
68
+ };
140
69
 
141
70
  return (
142
71
  <div className={`youtube-embed-container my-6 ${className}`}>
143
- {/* Video Title */}
144
72
  {title && showTitle && (
145
73
  <div className="video-title font-sans text-lg font-medium text-ods-text-primary mb-3">
146
74
  {title}
147
75
  </div>
148
76
  )}
149
77
 
150
- {/* Video Container with 16:9 Aspect Ratio */}
151
78
  <div className="video-wrapper relative w-full" style={{ paddingBottom: '56.25%' }}>
152
- {/* Loading State */}
153
- {isLoading && (
154
- <div className="loading-overlay absolute inset-0 bg-ods-card border border-ods-border rounded-lg flex items-center justify-center">
155
- <div className="loading-content flex flex-col items-center gap-3">
156
- <Loader className="animate-spin text-ods-accent" size={32} />
157
- <span className="font-sans text-sm text-ods-text-secondary">Loading video...</span>
158
- </div>
159
- </div>
160
- )}
161
-
162
- {/* Video Player */}
163
- <div className="video-player absolute inset-0 rounded-lg overflow-hidden border border-ods-border bg-ods-bg-inverse">
164
- {useIframe ? (
165
- // Iframe fallback for mobile and error cases
166
- <iframe
167
- src={embedUrl}
168
- title={title}
169
- className="w-full h-full border-0"
170
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
171
- allowFullScreen
172
- style={{ border: 'none' }}
173
- />
174
- ) : (
175
- <ReactPlayer
176
- url={videoUrl}
177
- width="100%"
178
- height="100%"
179
- onReady={handleReady}
180
- onError={handleError}
181
- onPlay={handlePlay}
182
- onPause={handlePause}
183
- config={{
184
- youtube: {
185
- playerVars: {
186
- autoplay: 0,
187
- controls: 1,
188
- rel: 0,
189
- showinfo: 0,
190
- modestbranding: 1,
191
- iv_load_policy: 3,
192
- cc_load_policy: 0,
193
- playsinline: 1
194
- }
195
- }
196
- } as any}
197
- light={false} // Show video immediately
198
- playing={false} // Don't autoplay
199
- />
200
- )}
201
- </div>
202
-
203
- {/* Custom Play Button Overlay (optional enhancement) - only for ReactPlayer */}
204
- {!useIframe && !isPlaying && !isLoading && (
205
- <div className="play-overlay absolute inset-0 flex items-center justify-center bg-ods-bg-inverse bg-opacity-20 rounded-lg transition-opacity duration-300 hover:bg-opacity-30">
206
- <button
207
- onClick={() => setIsPlaying(true)}
208
- className="play-button bg-ods-accent hover:bg-ods-accent-hover text-ods-text-on-accent w-16 h-16 rounded-full flex items-center justify-center transition-all duration-200 transform hover:scale-110 shadow-lg"
209
- aria-label="Play video"
79
+ <div className="absolute inset-0 rounded-lg overflow-hidden border border-ods-border bg-ods-card">
80
+ <div ref={iframeSlotRef} className="absolute inset-0" aria-hidden={!activated} />
81
+ {!activated && (
82
+ <button
83
+ type="button"
84
+ aria-label={`Play: ${title}`}
85
+ onClick={handleActivate}
86
+ className="group absolute inset-0 p-0 m-0 border-0 cursor-pointer bg-transparent"
210
87
  >
211
- <Play size={24} className="ml-1" />
88
+ <picture>
89
+ <source type="image/webp" srcSet={posterWebp} />
90
+ <img
91
+ src={posterJpg}
92
+ alt={title}
93
+ loading="lazy"
94
+ fetchPriority={aboveTheFold ? 'high' : 'low'}
95
+ decoding={aboveTheFold ? 'sync' : 'async'}
96
+ className="absolute inset-0 w-full h-full object-cover"
97
+ />
98
+ </picture>
99
+ <div className="absolute inset-0 flex items-center justify-center bg-ods-bg-inverse bg-opacity-20 transition-opacity duration-200 group-hover:bg-opacity-30">
100
+ <span className="flex items-center justify-center w-16 h-16 rounded-full bg-ods-accent text-ods-text-on-accent shadow-lg transition-transform duration-200 group-hover:scale-110">
101
+ <svg width={24} height={24} fill="currentColor" viewBox="0 0 24 24" className="ml-1">
102
+ <polygon points="5,3 19,12 5,21" />
103
+ </svg>
104
+ </span>
105
+ </div>
212
106
  </button>
213
- </div>
214
- )}
107
+ )}
108
+ </div>
215
109
  </div>
216
110
 
217
- {/* Video Meta Information */}
218
111
  {showMeta && (
219
112
  <div className="video-meta flex items-center justify-between mt-3 text-sm text-ods-text-secondary">
220
- <div className="video-platform font-sans">
221
- YouTube
222
- </div>
113
+ <div className="video-platform font-sans">YouTube</div>
223
114
  <a
224
- href={videoUrl}
115
+ href={watchUrl}
225
116
  target="_blank"
226
117
  rel="noopener noreferrer"
227
118
  className="video-link font-sans text-ods-accent hover:text-ods-accent-hover transition-colors duration-200"
@@ -234,42 +125,34 @@ export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
234
125
  );
235
126
  };
236
127
 
237
- // Utility function to extract YouTube video ID from various URL formats
128
+ const YT_HOSTS = new Set([
129
+ 'youtube.com', 'www.youtube.com', 'm.youtube.com',
130
+ 'youtu.be',
131
+ 'youtube-nocookie.com', 'www.youtube-nocookie.com',
132
+ ]);
133
+
134
+ // `youtube.com/(embed|v|shorts)/<id>` — anchored, no `.*`, ReDoS-safe.
135
+ const YT_PATH_RE = /^\/(?:embed|v|shorts)\/([^/]+)\/?$/;
136
+
137
+ /**
138
+ * Extract the YouTube video id from any common URL shape. Uses `URL`
139
+ * parsing + a strict, anchored pathname regex — NOT the previous
140
+ * `.*v=` pattern that CodeQL flagged for polynomial-time backtracking
141
+ * on adversarial input like `youtube.com/watch?` repeated N times.
142
+ */
238
143
  export const extractYouTubeId = (url: string): string | null => {
239
- const patterns = [
240
- /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
241
- /youtube\.com\/v\/([^&\n?#]+)/,
242
- /youtube\.com\/watch\?.*v=([^&\n?#]+)/
243
- ];
244
-
245
- for (const pattern of patterns) {
246
- const match = url.match(pattern);
247
- if (match) {
248
- return match[1];
249
- }
144
+ let u: URL;
145
+ try { u = new URL(url); } catch { return null; }
146
+ if (!YT_HOSTS.has(u.hostname.toLowerCase())) return null;
147
+ // `youtu.be/<id>` — id is the first path segment.
148
+ if (u.hostname.toLowerCase().endsWith('youtu.be')) {
149
+ return u.pathname.split('/').filter(Boolean)[0] ?? null;
250
150
  }
251
-
252
- return null;
151
+ // `youtube.com/watch?v=<id>` — query parameter.
152
+ const v = u.searchParams.get('v');
153
+ if (v) return v;
154
+ // `youtube.com/(embed|v|shorts)/<id>` — anchored pathname match.
155
+ const m = u.pathname.match(YT_PATH_RE);
156
+ return m ? m[1] : null;
253
157
  };
254
158
 
255
- // Component for parsing YouTube URLs in markdown
256
- export const YouTubeLinkParser: React.FC<{ href: string; children: React.ReactNode }> = ({ href, children }) => {
257
- const videoId = extractYouTubeId(href);
258
-
259
- // If it's a YouTube URL, render the embed instead of a link
260
- if (videoId) {
261
- return <YouTubeEmbed videoId={videoId} title={typeof children === 'string' ? children : undefined} />;
262
- }
263
-
264
- // Otherwise, render as a normal link
265
- return (
266
- <a
267
- href={href}
268
- target="_blank"
269
- rel="noopener noreferrer"
270
- className="text-ods-accent hover:text-ods-accent-hover transition-colors duration-200"
271
- >
272
- {children}
273
- </a>
274
- );
275
- };