@flamingo-stack/openframe-frontend-core 0.0.178 → 0.0.179
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-L4T24AN4.cjs → chunk-4TM2SBMX.cjs} +855 -1325
- package/dist/chunk-4TM2SBMX.cjs.map +1 -0
- package/dist/{chunk-AAX27BCR.js → chunk-ZMQP3UZJ.js} +3690 -4160
- package/dist/chunk-ZMQP3UZJ.js.map +1 -0
- package/dist/components/features/entity-video-section.d.ts +54 -0
- package/dist/components/features/entity-video-section.d.ts.map +1 -0
- package/dist/components/features/index.cjs +18 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +4 -2
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +21 -5
- package/dist/components/features/video-bites-display.d.ts +38 -0
- package/dist/components/features/video-bites-display.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +62 -0
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +94 -0
- package/dist/components/features/video.d.ts.map +1 -0
- package/dist/components/index.cjs +18 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +21 -5
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +18 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +21 -5
- package/package.json +2 -2
- package/src/components/features/entity-video-section.tsx +175 -0
- package/src/components/features/index.ts +9 -2
- package/src/components/features/video-bites-display.tsx +216 -0
- package/src/components/features/video-ratio-tabs.tsx +174 -0
- package/src/components/features/video.tsx +474 -0
- package/src/components/media-carousel.tsx +43 -236
- package/src/components/shared/product-release/release-detail-page.tsx +26 -19
- package/dist/chunk-AAX27BCR.js.map +0 -1
- package/dist/chunk-L4T24AN4.cjs.map +0 -1
- package/dist/components/features/video-player.d.ts +0 -44
- package/dist/components/features/video-player.d.ts.map +0 -1
- package/dist/components/features/youtube-embed.d.ts +0 -31
- package/dist/components/features/youtube-embed.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
- package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed.d.ts +0 -9
- package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
- package/src/components/features/.video-player.md +0 -44
- package/src/components/features/.youtube-embed.md +0 -40
- package/src/components/features/video-player.tsx +0 -893
- package/src/components/features/youtube-embed.tsx +0 -158
- package/src/utils/lite-youtube-embed-stub.tsx +0 -21
- package/src/utils/lite-youtube-embed.tsx +0 -46
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <Video> — single source of truth for every public video surface
|
|
5
|
+
* across every Flamingo platform consumer of this lib.
|
|
6
|
+
*
|
|
7
|
+
* One component, three sources, three layouts. Replaces and deletes
|
|
8
|
+
* the previous lib primitives:
|
|
9
|
+
*
|
|
10
|
+
* - `<VideoPlayer>` (react-player wrapper, ~900 LOC custom controls)
|
|
11
|
+
* - `<YouTubeEmbed>` (separate lite-youtube facade)
|
|
12
|
+
*
|
|
13
|
+
* Routing (`kind` discriminant, default `'auto'`):
|
|
14
|
+
*
|
|
15
|
+
* kind="youtube" → inline lite-youtube facade (poster + click→iframe)
|
|
16
|
+
* kind="file" → <MuxPlayer> (HLS + MP4 + Mux Data + CMCD all in one)
|
|
17
|
+
* kind="auto" → strict URL parse:
|
|
18
|
+
* bare 11-char id → youtube
|
|
19
|
+
* YouTube hostname → youtube
|
|
20
|
+
* anything else → file
|
|
21
|
+
*
|
|
22
|
+
* `<MuxPlayer>` handles both `.m3u8` (HLS via hls.js, native on Safari)
|
|
23
|
+
* AND plain `.mp4` (uses the underlying `<video>` element). One component,
|
|
24
|
+
* both paths — no internal "HLS vs MP4" branch needed. Captions are
|
|
25
|
+
* rendered as native `<track>` children when `captionsUrl` is passed.
|
|
26
|
+
*
|
|
27
|
+
* Layouts:
|
|
28
|
+
* layout="centered" → max-w-3xl centered wrapper. Detail-page surface.
|
|
29
|
+
* layout="fill" → absolute inset-0 w-full h-full. Carousel slides.
|
|
30
|
+
* layout="native" → intrinsic aspect ratio. Bites grid, blog cards.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
34
|
+
import MuxPlayer from '@mux/mux-player-react';
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// URL classifiers (private — `<Video>` is the only consumer)
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const YT_HOSTS = new Set([
|
|
41
|
+
'youtube.com', 'www.youtube.com', 'm.youtube.com',
|
|
42
|
+
'youtu.be',
|
|
43
|
+
'youtube-nocookie.com', 'www.youtube-nocookie.com',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Strict YouTube URL detection — parses the URL and checks the hostname. */
|
|
47
|
+
function isYouTubeUrl(url: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
return YT_HOSTS.has(new URL(url, 'http://placeholder.local').hostname.toLowerCase());
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `youtube.com/(embed|v|shorts)/<id>` — anchored, no `.*`, ReDoS-safe.
|
|
56
|
+
const YT_PATH_RE = /^\/(?:embed|v|shorts)\/([^/]+)\/?$/;
|
|
57
|
+
|
|
58
|
+
// Bare 11-char YouTube id — base62 alphabet `[A-Za-z0-9_-]`.
|
|
59
|
+
// Anchored, no `.*`, ReDoS-safe; rejects anything that contains `/` or `:`.
|
|
60
|
+
const BARE_YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the YouTube video id from any common URL shape OR from a
|
|
64
|
+
* bare 11-char id (carousels and some admin shapes pass that form
|
|
65
|
+
* directly).
|
|
66
|
+
*
|
|
67
|
+
* Uses strict `URL` parsing + an anchored pathname regex — NOT the
|
|
68
|
+
* legacy `.*v=` pattern that CodeQL flagged for polynomial-time
|
|
69
|
+
* backtracking on adversarial input like `youtube.com/watch?`
|
|
70
|
+
* repeated N times. Bare-id fallback uses an anchored character-class
|
|
71
|
+
* regex so it can never ReDoS either.
|
|
72
|
+
*
|
|
73
|
+
* Exported so admin tooling and carousel thumbnail logic can validate
|
|
74
|
+
* a URL without rendering the full `<Video>` component.
|
|
75
|
+
*/
|
|
76
|
+
export function extractYouTubeId(url: string): string | null {
|
|
77
|
+
if (!url) return null;
|
|
78
|
+
// Bare-id form first — `new URL('dQw4w9WgXcQ')` throws, so this MUST
|
|
79
|
+
// run before the URL parse. The 11-char anchored regex rejects URLs
|
|
80
|
+
// (which always contain `:` or `/`).
|
|
81
|
+
if (BARE_YT_ID_RE.test(url)) return url;
|
|
82
|
+
let u: URL;
|
|
83
|
+
// Match `isYouTubeUrl`'s relative-safe parsing — a base URL with a
|
|
84
|
+
// placeholder origin lets us handle protocol-relative inputs (`//youtube.com/...`)
|
|
85
|
+
// and protocol-less inputs (`youtube.com/...`) without throwing.
|
|
86
|
+
try { u = new URL(url, 'http://placeholder.local'); } catch { return null; }
|
|
87
|
+
if (!YT_HOSTS.has(u.hostname.toLowerCase())) return null;
|
|
88
|
+
// `youtu.be/<id>` — id is the first non-empty path segment.
|
|
89
|
+
if (u.hostname.toLowerCase().endsWith('youtu.be')) {
|
|
90
|
+
return u.pathname.split('/').filter(Boolean)[0] ?? null;
|
|
91
|
+
}
|
|
92
|
+
// `youtube.com/watch?v=<id>` — query parameter.
|
|
93
|
+
const v = u.searchParams.get('v');
|
|
94
|
+
if (v) return v;
|
|
95
|
+
// `youtube.com/(embed|v|shorts)/<id>` — anchored pathname match.
|
|
96
|
+
const m = u.pathname.match(YT_PATH_RE);
|
|
97
|
+
return m ? m[1] : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Props
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
export type VideoLayout = 'centered' | 'fill' | 'native';
|
|
105
|
+
|
|
106
|
+
interface VideoCommonProps {
|
|
107
|
+
/** Layout wrapper. Detail pages pass `"centered"`. Default `"native"`. */
|
|
108
|
+
layout?: VideoLayout;
|
|
109
|
+
/** Poster / thumbnail. */
|
|
110
|
+
poster?: string | null;
|
|
111
|
+
/** Mute by default — for autoplay carousels. */
|
|
112
|
+
muted?: boolean;
|
|
113
|
+
/** LCP hint — YouTube facade poster gets `fetchpriority="high"`. */
|
|
114
|
+
priority?: boolean;
|
|
115
|
+
/** Tailwind classes applied to the underlying player root. */
|
|
116
|
+
className?: string;
|
|
117
|
+
/** Accessible label (used as YT facade title; ignored for file branch). */
|
|
118
|
+
title?: string;
|
|
119
|
+
/**
|
|
120
|
+
* YouTube-only: hide YT player chrome (controls, info, fullscreen, related
|
|
121
|
+
* videos, keyboard shortcuts). Used for marketing/landing-page embeds that
|
|
122
|
+
* want a minimal look. No-op for file (MP4/HLS) branches.
|
|
123
|
+
*/
|
|
124
|
+
minimalControls?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface VideoFileProps extends VideoCommonProps {
|
|
128
|
+
kind: 'file';
|
|
129
|
+
url: string;
|
|
130
|
+
/**
|
|
131
|
+
* SRT raw content. Deprecated: pass `captionsUrl` (VTT) instead.
|
|
132
|
+
* Native `<track>` requires a URL; raw SRT can't be rendered without
|
|
133
|
+
* a custom overlay. Setting this without `captionsUrl` is a no-op
|
|
134
|
+
* with a dev warning.
|
|
135
|
+
*/
|
|
136
|
+
srtContent?: string | null;
|
|
137
|
+
/** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
|
|
138
|
+
captionsUrl?: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface VideoYouTubeProps extends VideoCommonProps {
|
|
142
|
+
kind: 'youtube';
|
|
143
|
+
/** Either a full YT URL or just the video id. */
|
|
144
|
+
url: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface VideoAutoProps extends VideoCommonProps {
|
|
148
|
+
kind?: 'auto';
|
|
149
|
+
url: string;
|
|
150
|
+
srtContent?: string | null;
|
|
151
|
+
captionsUrl?: string | null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// Component
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
export function Video(props: VideoProps): React.ReactElement | null {
|
|
161
|
+
const url = props.url;
|
|
162
|
+
if (!url) return null;
|
|
163
|
+
|
|
164
|
+
const effectiveKind = resolveKind(props, url);
|
|
165
|
+
const layout = props.layout ?? 'native';
|
|
166
|
+
|
|
167
|
+
const inner =
|
|
168
|
+
effectiveKind === 'youtube' ? (
|
|
169
|
+
<YouTubeFacade
|
|
170
|
+
url={url}
|
|
171
|
+
title={props.title}
|
|
172
|
+
priority={props.priority}
|
|
173
|
+
className={props.className}
|
|
174
|
+
minimalControls={props.minimalControls}
|
|
175
|
+
/>
|
|
176
|
+
) : (
|
|
177
|
+
<FilePlayer
|
|
178
|
+
url={url}
|
|
179
|
+
poster={props.poster}
|
|
180
|
+
muted={props.muted}
|
|
181
|
+
srtContent={'srtContent' in props ? props.srtContent : null}
|
|
182
|
+
captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
|
|
183
|
+
className={props.className}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return wrapWithLayout(inner, layout);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Internals — never imported by call sites; `<Video>` is the only entry.
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the rendering branch. `'auto'` (or no `kind`) inspects the URL:
|
|
196
|
+
* YouTube host (or a bare 11-char video id) → 'youtube', anything else →
|
|
197
|
+
* 'file' (HLS / MP4 both handled by MuxPlayer). Type-safe — no `as` casts.
|
|
198
|
+
*/
|
|
199
|
+
function resolveKind(props: VideoProps, url: string): 'youtube' | 'file' {
|
|
200
|
+
if ('kind' in props) {
|
|
201
|
+
if (props.kind === 'youtube') return 'youtube';
|
|
202
|
+
if (props.kind === 'file') return 'file';
|
|
203
|
+
// kind === 'auto' falls through to URL-based detection
|
|
204
|
+
}
|
|
205
|
+
// Bare 11-char YouTube id — use the same anchored regex as
|
|
206
|
+
// `extractYouTubeId` so both code paths agree on which strings are
|
|
207
|
+
// bare ids vs. URLs (avoids the regression where `videos/clip`
|
|
208
|
+
// length-11 strings were mis-routed to YouTube).
|
|
209
|
+
if (BARE_YT_ID_RE.test(url)) return 'youtube';
|
|
210
|
+
return isYouTubeUrl(url) ? 'youtube' : 'file';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function wrapWithLayout(
|
|
214
|
+
inner: React.ReactElement,
|
|
215
|
+
layout: VideoLayout,
|
|
216
|
+
): React.ReactElement {
|
|
217
|
+
switch (layout) {
|
|
218
|
+
case 'centered':
|
|
219
|
+
// `aspect-video` (16:9) reserves the box from first paint so MuxPlayer
|
|
220
|
+
// doesn't flicker tiny→full while video metadata loads. Both branches
|
|
221
|
+
// are sized to fill 100% of this container (MuxPlayer via `style`,
|
|
222
|
+
// YouTube facade via internal `paddingBottom: 56.25%` which compounds
|
|
223
|
+
// harmlessly inside an already-16:9 box).
|
|
224
|
+
return (
|
|
225
|
+
<div className="flex justify-center w-full">
|
|
226
|
+
<div className="w-full max-w-3xl aspect-video">{inner}</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
case 'fill':
|
|
230
|
+
return <div className="absolute inset-0 w-full h-full">{inner}</div>;
|
|
231
|
+
case 'native':
|
|
232
|
+
default:
|
|
233
|
+
// `native` callers (LazyBite in `<VideoBitesDisplay>`, blog cards) are
|
|
234
|
+
// expected to provide their own aspect-ratio container so the layout
|
|
235
|
+
// primitive doesn't override portrait/square/landscape bites with 16:9.
|
|
236
|
+
return inner;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -----------------------------------------------------------------------------
|
|
241
|
+
// File branch — MuxPlayer (handles both .m3u8 HLS and plain .mp4)
|
|
242
|
+
// -----------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
interface FilePlayerProps {
|
|
245
|
+
url: string;
|
|
246
|
+
poster?: string | null;
|
|
247
|
+
muted?: boolean;
|
|
248
|
+
srtContent?: string | null;
|
|
249
|
+
captionsUrl?: string | null;
|
|
250
|
+
className?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function FilePlayer({
|
|
254
|
+
url,
|
|
255
|
+
poster,
|
|
256
|
+
muted,
|
|
257
|
+
srtContent,
|
|
258
|
+
captionsUrl,
|
|
259
|
+
className,
|
|
260
|
+
}: FilePlayerProps): React.ReactElement {
|
|
261
|
+
// Raw SRT text is unusable without a custom overlay — and we just deleted
|
|
262
|
+
// the 900-LOC custom-controls layer that owned that overlay. Consumers
|
|
263
|
+
// pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
|
|
264
|
+
// anyway. Warn in dev if the deprecated prop is the only one supplied
|
|
265
|
+
// so a single-prop call site doesn't silently lose captions.
|
|
266
|
+
if (process.env.NODE_ENV !== 'production' && srtContent && !captionsUrl) {
|
|
267
|
+
// eslint-disable-next-line no-console
|
|
268
|
+
console.warn(
|
|
269
|
+
'[Video] srtContent supplied without captionsUrl — captions will not render. ' +
|
|
270
|
+
'Pass captionsUrl (the VTT URL) instead; raw SRT text overlays are no longer supported.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<MuxPlayer
|
|
276
|
+
src={url}
|
|
277
|
+
poster={poster || undefined}
|
|
278
|
+
streamType="on-demand"
|
|
279
|
+
playsInline
|
|
280
|
+
muted={muted}
|
|
281
|
+
preferCmcd="header"
|
|
282
|
+
accentColor="var(--ods-accent)"
|
|
283
|
+
className={className}
|
|
284
|
+
// Fill the wrapping aspect-ratio container instead of MuxPlayer's
|
|
285
|
+
// intrinsic size. Without this, MuxPlayer renders at its default
|
|
286
|
+
// dimensions before video metadata loads, then grows to its
|
|
287
|
+
// metadata-derived size — that's the "starts super small and
|
|
288
|
+
// flickers and grows" CLS we're killing. With `aspect-video` on
|
|
289
|
+
// the centered wrapper and `width/height: 100%` here, the box is
|
|
290
|
+
// 16:9 from first paint and stays put.
|
|
291
|
+
style={{ width: '100%', height: '100%' }}
|
|
292
|
+
>
|
|
293
|
+
{captionsUrl ? (
|
|
294
|
+
<track
|
|
295
|
+
kind="captions"
|
|
296
|
+
src={captionsUrl}
|
|
297
|
+
srcLang="en"
|
|
298
|
+
label="English"
|
|
299
|
+
default
|
|
300
|
+
/>
|
|
301
|
+
) : null}
|
|
302
|
+
</MuxPlayer>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -----------------------------------------------------------------------------
|
|
307
|
+
// YouTube facade — inlined lite-youtube-embed pattern
|
|
308
|
+
// -----------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
interface YouTubeFacadeProps {
|
|
311
|
+
url: string;
|
|
312
|
+
title?: string;
|
|
313
|
+
priority?: boolean;
|
|
314
|
+
className?: string;
|
|
315
|
+
minimalControls?: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function YouTubeFacade({
|
|
319
|
+
url,
|
|
320
|
+
title = 'YouTube Video',
|
|
321
|
+
priority,
|
|
322
|
+
className,
|
|
323
|
+
minimalControls,
|
|
324
|
+
}: YouTubeFacadeProps): React.ReactElement | null {
|
|
325
|
+
// `extractYouTubeId` handles both bare 11-char ids AND full URLs in a
|
|
326
|
+
// single call site, so the resolution logic lives in exactly one place.
|
|
327
|
+
const videoId = extractYouTubeId(url);
|
|
328
|
+
if (!videoId) return null;
|
|
329
|
+
|
|
330
|
+
return <YouTubeFacadeInner videoId={videoId} title={title} priority={priority} className={className} minimalControls={minimalControls} />;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
interface YouTubeFacadeInnerProps {
|
|
334
|
+
videoId: string;
|
|
335
|
+
title: string;
|
|
336
|
+
priority?: boolean;
|
|
337
|
+
className?: string;
|
|
338
|
+
minimalControls?: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function YouTubeFacadeInner({
|
|
342
|
+
videoId,
|
|
343
|
+
title,
|
|
344
|
+
priority,
|
|
345
|
+
className,
|
|
346
|
+
minimalControls,
|
|
347
|
+
}: YouTubeFacadeInnerProps): React.ReactElement {
|
|
348
|
+
const [activated, setActivated] = useState(false);
|
|
349
|
+
// Wrapper ref used by the outside-click dismissal — clicks inside this
|
|
350
|
+
// box keep the iframe mounted; clicks anywhere else tear the iframe
|
|
351
|
+
// down so YouTube's persistent native controls go with it.
|
|
352
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
353
|
+
|
|
354
|
+
const embedParams = new URLSearchParams({
|
|
355
|
+
autoplay: '1',
|
|
356
|
+
rel: '0',
|
|
357
|
+
modestbranding: '1',
|
|
358
|
+
playsinline: '1',
|
|
359
|
+
});
|
|
360
|
+
if (minimalControls) {
|
|
361
|
+
embedParams.set('controls', '0');
|
|
362
|
+
embedParams.set('showinfo', '0');
|
|
363
|
+
embedParams.set('fs', '0');
|
|
364
|
+
embedParams.set('iv_load_policy', '3');
|
|
365
|
+
embedParams.set('cc_load_policy', '0');
|
|
366
|
+
embedParams.set('disablekb', '1');
|
|
367
|
+
}
|
|
368
|
+
const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?${embedParams.toString()}`;
|
|
369
|
+
const posterJpg = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
|
|
370
|
+
const posterWebp = `https://i.ytimg.com/vi_webp/${videoId}/mqdefault.webp`;
|
|
371
|
+
|
|
372
|
+
// Outside-click dismissal — when the iframe is mounted, listen for any
|
|
373
|
+
// pointerdown on the document. Clicks INSIDE the wrapper bubble through
|
|
374
|
+
// the iframe DOM element but never reach our handler when they occur on
|
|
375
|
+
// YouTube's own UI (the iframe is a separate browsing context, so
|
|
376
|
+
// pointer events fired inside it don't propagate to our document).
|
|
377
|
+
// Clicks OUTSIDE the wrapper fire here normally — we tear down the
|
|
378
|
+
// iframe by flipping `activated` to false. The iframe unmounts, taking
|
|
379
|
+
// YouTube's persistent native controls with it. A second click on the
|
|
380
|
+
// play poster re-mounts the iframe with `autoplay=1` (a user gesture,
|
|
381
|
+
// so iOS Safari + Chrome autoplay restrictions are satisfied).
|
|
382
|
+
//
|
|
383
|
+
// We hook `pointerdown` (not `click`) so the dismissal feels instant —
|
|
384
|
+
// by the time `click` fires the user has already seen the controls
|
|
385
|
+
// overlay another beat.
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!activated) return;
|
|
388
|
+
|
|
389
|
+
function handleOutsideClick(event: PointerEvent) {
|
|
390
|
+
const target = event.target as Node | null;
|
|
391
|
+
if (!target) return;
|
|
392
|
+
if (wrapperRef.current?.contains(target)) return;
|
|
393
|
+
setActivated(false);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
document.addEventListener('pointerdown', handleOutsideClick);
|
|
397
|
+
return () => document.removeEventListener('pointerdown', handleOutsideClick);
|
|
398
|
+
}, [activated]);
|
|
399
|
+
|
|
400
|
+
// Escape-key dismissal — keyboard users should have parity with the
|
|
401
|
+
// pointer outside-click. Same tear-down semantics.
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!activated) return;
|
|
404
|
+
|
|
405
|
+
function handleEscape(event: KeyboardEvent) {
|
|
406
|
+
if (event.key === 'Escape') setActivated(false);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
document.addEventListener('keydown', handleEscape);
|
|
410
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
411
|
+
}, [activated]);
|
|
412
|
+
|
|
413
|
+
// Early-return rendering. The previous imperative implementation
|
|
414
|
+
// (`document.createElement('iframe')` + state flip) had a subtle bug
|
|
415
|
+
// where the play-button overlay could linger past activation because
|
|
416
|
+
// React's commit phase and the imperative DOM mutation raced. Two
|
|
417
|
+
// mutually-exclusive return paths eliminate that race entirely — when
|
|
418
|
+
// `activated` flips, React unmounts the button branch and mounts the
|
|
419
|
+
// iframe branch in a single commit.
|
|
420
|
+
//
|
|
421
|
+
// Autoplay on iOS Safari: the user gesture (the button's onClick) and
|
|
422
|
+
// the iframe mount happen in the SAME React commit, which flushes
|
|
423
|
+
// synchronously inside event handlers. iOS treats the iframe insertion
|
|
424
|
+
// as still being inside the user-activation tick, so `autoplay=1` plays.
|
|
425
|
+
// (Verified empirically; lite-youtube-embed uses imperative DOM for
|
|
426
|
+
// legacy-React compatibility — modern React's sync-commit-on-event
|
|
427
|
+
// makes the JSX path equivalent.)
|
|
428
|
+
const wrapperClass = `relative w-full ${className ?? ''}`;
|
|
429
|
+
const wrapperStyle = { paddingBottom: '56.25%' as const };
|
|
430
|
+
|
|
431
|
+
if (activated) {
|
|
432
|
+
return (
|
|
433
|
+
<div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
|
|
434
|
+
<iframe
|
|
435
|
+
src={embedUrl}
|
|
436
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
437
|
+
allowFullScreen
|
|
438
|
+
title={title}
|
|
439
|
+
className="absolute inset-0 w-full h-full border-0 rounded-lg"
|
|
440
|
+
/>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
aria-label={`Play: ${title}`}
|
|
450
|
+
onClick={() => setActivated(true)}
|
|
451
|
+
className="group absolute inset-0 p-0 m-0 border border-ods-border rounded-lg overflow-hidden bg-ods-card cursor-pointer"
|
|
452
|
+
>
|
|
453
|
+
<picture>
|
|
454
|
+
<source type="image/webp" srcSet={posterWebp} />
|
|
455
|
+
<img
|
|
456
|
+
src={posterJpg}
|
|
457
|
+
alt={title}
|
|
458
|
+
loading="lazy"
|
|
459
|
+
fetchPriority={priority ? 'high' : 'low'}
|
|
460
|
+
decoding={priority ? 'sync' : 'async'}
|
|
461
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
462
|
+
/>
|
|
463
|
+
</picture>
|
|
464
|
+
<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">
|
|
465
|
+
<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">
|
|
466
|
+
<svg width={24} height={24} fill="currentColor" viewBox="0 0 24 24" className="ml-1">
|
|
467
|
+
<polygon points="5,3 19,12 5,21" />
|
|
468
|
+
</svg>
|
|
469
|
+
</span>
|
|
470
|
+
</div>
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|