@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.
- package/dist/{chunk-6LDN3CIY.js → chunk-AAX27BCR.js} +189 -348
- package/dist/chunk-AAX27BCR.js.map +1 -0
- package/dist/{chunk-WX7PT5C7.cjs → chunk-ALW3D72O.cjs} +61 -2
- package/dist/chunk-ALW3D72O.cjs.map +1 -0
- package/dist/{chunk-KB2N44BY.js → chunk-FMWHOUFE.js} +61 -2
- package/dist/chunk-FMWHOUFE.js.map +1 -0
- package/dist/{chunk-C6ZMI4UB.cjs → chunk-L4T24AN4.cjs} +113 -272
- package/dist/chunk-L4T24AN4.cjs.map +1 -0
- package/dist/components/features/index.cjs +3 -5
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -4
- package/dist/components/features/video-player.d.ts +17 -20
- package/dist/components/features/video-player.d.ts.map +1 -1
- package/dist/components/features/youtube-embed.d.ts +18 -4
- package/dist/components/features/youtube-embed.d.ts.map +1 -1
- package/dist/components/index.cjs +3 -5
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +2 -4
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/use-near-viewport.d.ts +42 -0
- package/dist/hooks/use-near-viewport.d.ts.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/package.json +1 -1
- package/src/components/features/video-player.tsx +39 -176
- package/src/components/features/youtube-embed.tsx +107 -224
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-near-viewport.ts +118 -0
- package/dist/chunk-6LDN3CIY.js.map +0 -1
- package/dist/chunk-C6ZMI4UB.cjs.map +0 -1
- package/dist/chunk-KB2N44BY.js.map +0 -1
- package/dist/chunk-WX7PT5C7.cjs.map +0 -1
- 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
|
-
*
|
|
241
|
-
*
|
|
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
|
|
247
|
-
*
|
|
248
|
-
* -
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
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:
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
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
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
}, [
|
|
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:
|
|
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, {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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 [
|
|
44
|
-
const
|
|
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
|
|
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');
|
|
87
|
-
embedParams.set('showinfo', '0');
|
|
88
|
-
embedParams.set('fs', '0');
|
|
89
|
-
embedParams.set('iv_load_policy', '3');
|
|
90
|
-
embedParams.set('cc_load_policy', '0');
|
|
91
|
-
embedParams.set('disablekb', '1');
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
};
|