@flamingo-stack/openframe-frontend-core 0.0.182 → 0.0.183
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-IWMK4MH4.cjs → chunk-JHWVLIFZ.cjs} +280 -245
- package/dist/chunk-JHWVLIFZ.cjs.map +1 -0
- package/dist/{chunk-CLZ3QQMJ.js → chunk-Z2A6RJCK.js} +104 -69
- package/dist/chunk-Z2A6RJCK.js.map +1 -0
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/features/video-source-selector.d.ts +3 -3
- package/dist/components/features/video-source-selector.d.ts.map +1 -1
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/index.cjs +2 -2
- package/dist/components/index.js +1 -1
- 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/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/types/case-study.d.ts +2 -2
- package/dist/types/case-study.d.ts.map +1 -1
- package/dist/types/supabase.d.ts +6 -6
- package/dist/types/supabase.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/features/video-source-selector.tsx +11 -11
- package/src/components/features/video.tsx +126 -75
- package/src/components/media-carousel.tsx +2 -3
- package/src/types/case-study.ts +2 -2
- package/src/types/supabase.ts +6 -6
- package/dist/chunk-CLZ3QQMJ.js.map +0 -1
- package/dist/chunk-IWMK4MH4.cjs.map +0 -1
|
@@ -30,8 +30,9 @@
|
|
|
30
30
|
* layout="native" → intrinsic aspect ratio. Bites grid, blog cards.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
33
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
34
34
|
import MuxPlayer from '@mux/mux-player-react';
|
|
35
|
+
import { PlayIcon } from '../icons-v2-generated/media-playback/play-icon';
|
|
35
36
|
|
|
36
37
|
// =============================================================================
|
|
37
38
|
// URL classifiers (private — `<Video>` is the only consumer)
|
|
@@ -338,6 +339,26 @@ interface YouTubeFacadeInnerProps {
|
|
|
338
339
|
minimalControls?: boolean;
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
const YT_NOCOOKIE_ORIGIN = 'https://www.youtube-nocookie.com';
|
|
343
|
+
|
|
344
|
+
// YouTube IFrame Player API state codes — documented integers.
|
|
345
|
+
// https://developers.google.com/youtube/iframe_api_reference#Playback_status
|
|
346
|
+
const YT_STATE_ENDED = 0;
|
|
347
|
+
const YT_STATE_PLAYING = 1;
|
|
348
|
+
|
|
349
|
+
// Sub-second delay before we blur the iframe after PLAYING. Zero would
|
|
350
|
+
// cancel YouTube's mount-time "controls visible" intro flash entirely
|
|
351
|
+
// (jarring); ~1s lets the user briefly see playback started, then we
|
|
352
|
+
// kick YouTube's internal idle timer by removing DOM focus from the
|
|
353
|
+
// iframe. Net result: controls fade ~1s after playback begins,
|
|
354
|
+
// matching the user-locked target.
|
|
355
|
+
const YT_PLAYING_BLUR_DELAY_MS = 1000;
|
|
356
|
+
|
|
357
|
+
interface YouTubeInfoDeliveryMessage {
|
|
358
|
+
event?: string;
|
|
359
|
+
info?: { playerState?: number };
|
|
360
|
+
}
|
|
361
|
+
|
|
341
362
|
function YouTubeFacadeInner({
|
|
342
363
|
videoId,
|
|
343
364
|
title,
|
|
@@ -346,92 +367,124 @@ function YouTubeFacadeInner({
|
|
|
346
367
|
minimalControls,
|
|
347
368
|
}: YouTubeFacadeInnerProps): React.ReactElement {
|
|
348
369
|
const [activated, setActivated] = useState(false);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
370
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
371
|
+
|
|
372
|
+
// Embed URL + poster URLs only change when `videoId` or `minimalControls`
|
|
373
|
+
// do — memoize so we don't rebuild URLSearchParams on every render.
|
|
374
|
+
//
|
|
375
|
+
// `enablejsapi=1` opens the postMessage state channel we subscribe to
|
|
376
|
+
// below — without it, YouTube ignores `event:listening` messages and
|
|
377
|
+
// we can't detect PLAYING / ENDED to drive the auto-hide accelerator.
|
|
378
|
+
const { embedUrl, posterJpg, posterWebp } = useMemo(() => {
|
|
379
|
+
const params = new URLSearchParams({
|
|
380
|
+
autoplay: '1',
|
|
381
|
+
rel: '0',
|
|
382
|
+
modestbranding: '1',
|
|
383
|
+
playsinline: '1',
|
|
384
|
+
enablejsapi: '1',
|
|
385
|
+
});
|
|
386
|
+
if (minimalControls) {
|
|
387
|
+
params.set('controls', '0');
|
|
388
|
+
params.set('fs', '0');
|
|
389
|
+
params.set('iv_load_policy', '3');
|
|
390
|
+
params.set('cc_load_policy', '0');
|
|
391
|
+
params.set('disablekb', '1');
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
embedUrl: `${YT_NOCOOKIE_ORIGIN}/embed/${videoId}?${params.toString()}`,
|
|
395
|
+
posterJpg: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
|
396
|
+
posterWebp: `https://i.ytimg.com/vi_webp/${videoId}/mqdefault.webp`,
|
|
397
|
+
};
|
|
398
|
+
}, [videoId, minimalControls]);
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// YouTube control-fade accelerator (user-locked target: ~1s).
|
|
402
|
+
//
|
|
403
|
+
// YouTube's native idle timer for the bottom control bar is 5–10s when
|
|
404
|
+
// the iframe holds DOM focus. The IFrame Player API has no public
|
|
405
|
+
// `hideControls` command (full `func` list verified — none expose
|
|
406
|
+
// visibility). The one legal lever from outside the iframe is
|
|
407
|
+
// `iframe.blur()`, which kicks YouTube into its post-focus idle path
|
|
408
|
+
// (~2s minimum).
|
|
409
|
+
//
|
|
410
|
+
// Subscribe to YouTube's state channel via the documented lite-mode
|
|
411
|
+
// postMessage handshake — no full `iframe_api.js` library needed:
|
|
412
|
+
// <https://developers.google.com/youtube/iframe_api_reference>.
|
|
382
413
|
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
414
|
+
// - PLAYING (1) arrives once autoplay kicks in. Wait ~1s (so the user
|
|
415
|
+
// briefly sees that the player started), then blur the iframe so
|
|
416
|
+
// YouTube's idle timer fires immediately.
|
|
417
|
+
//
|
|
418
|
+
// - ENDED (0) → tear down the iframe. Playback is already over so
|
|
419
|
+
// there's nothing to interrupt, and removing the iframe kills the
|
|
420
|
+
// residual "More videos" suggestion grid YouTube leaves on screen.
|
|
421
|
+
//
|
|
422
|
+
// PAUSED (2) is deliberately unhandled — the user paused on purpose,
|
|
423
|
+
// leave YouTube's UI alone so they can resume.
|
|
424
|
+
//
|
|
425
|
+
// Playback is NEVER stopped by anything in this facade. Outside-click,
|
|
426
|
+
// Escape, tab-switch — all no-ops. The only state flip is on natural
|
|
427
|
+
// end-of-video (ENDED).
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
386
429
|
useEffect(() => {
|
|
387
430
|
if (!activated) return;
|
|
431
|
+
const iframe = iframeRef.current;
|
|
432
|
+
if (!iframe) return;
|
|
388
433
|
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
434
|
+
function subscribe() {
|
|
435
|
+
iframe?.contentWindow?.postMessage(
|
|
436
|
+
'{"event":"listening"}',
|
|
437
|
+
YT_NOCOOKIE_ORIGIN,
|
|
438
|
+
);
|
|
394
439
|
}
|
|
395
440
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
441
|
+
iframe.addEventListener('load', subscribe);
|
|
442
|
+
subscribe();
|
|
443
|
+
|
|
444
|
+
let blurTimer: ReturnType<typeof setTimeout> | null = null;
|
|
445
|
+
|
|
446
|
+
function handleMessage(event: MessageEvent) {
|
|
447
|
+
if (event.origin !== YT_NOCOOKIE_ORIGIN) return;
|
|
448
|
+
if (typeof event.data !== 'string') return;
|
|
449
|
+
let payload: YouTubeInfoDeliveryMessage | null = null;
|
|
450
|
+
try {
|
|
451
|
+
payload = JSON.parse(event.data);
|
|
452
|
+
} catch {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!payload || payload.event !== 'infoDelivery') return;
|
|
456
|
+
const state = payload.info?.playerState;
|
|
457
|
+
if (typeof state !== 'number') return;
|
|
458
|
+
|
|
459
|
+
if (state === YT_STATE_PLAYING) {
|
|
460
|
+
if (blurTimer !== null) return;
|
|
461
|
+
blurTimer = setTimeout(() => {
|
|
462
|
+
blurTimer = null;
|
|
463
|
+
iframeRef.current?.blur();
|
|
464
|
+
}, YT_PLAYING_BLUR_DELAY_MS);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (state === YT_STATE_ENDED) {
|
|
468
|
+
setActivated(false);
|
|
469
|
+
}
|
|
407
470
|
}
|
|
408
471
|
|
|
409
|
-
|
|
410
|
-
return () =>
|
|
472
|
+
window.addEventListener('message', handleMessage);
|
|
473
|
+
return () => {
|
|
474
|
+
iframe.removeEventListener('load', subscribe);
|
|
475
|
+
window.removeEventListener('message', handleMessage);
|
|
476
|
+
if (blurTimer !== null) clearTimeout(blurTimer);
|
|
477
|
+
};
|
|
411
478
|
}, [activated]);
|
|
412
479
|
|
|
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
480
|
const wrapperClass = `relative w-full ${className ?? ''}`;
|
|
429
481
|
const wrapperStyle = { paddingBottom: '56.25%' as const };
|
|
430
482
|
|
|
431
483
|
if (activated) {
|
|
432
484
|
return (
|
|
433
|
-
<div
|
|
485
|
+
<div className={wrapperClass} style={wrapperStyle}>
|
|
434
486
|
<iframe
|
|
487
|
+
ref={iframeRef}
|
|
435
488
|
src={embedUrl}
|
|
436
489
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
437
490
|
allowFullScreen
|
|
@@ -443,7 +496,7 @@ function YouTubeFacadeInner({
|
|
|
443
496
|
}
|
|
444
497
|
|
|
445
498
|
return (
|
|
446
|
-
<div
|
|
499
|
+
<div className={wrapperClass} style={wrapperStyle}>
|
|
447
500
|
<button
|
|
448
501
|
type="button"
|
|
449
502
|
aria-label={`Play: ${title}`}
|
|
@@ -463,9 +516,7 @@ function YouTubeFacadeInner({
|
|
|
463
516
|
</picture>
|
|
464
517
|
<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
518
|
<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
|
-
<
|
|
467
|
-
<polygon points="5,3 19,12 5,21" />
|
|
468
|
-
</svg>
|
|
519
|
+
<PlayIcon size={24} color="currentColor" className="ml-1" />
|
|
469
520
|
</span>
|
|
470
521
|
</div>
|
|
471
522
|
</button>
|
|
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
|
|
4
4
|
import { cn } from "../utils/cn";
|
|
5
5
|
import { MediaItem } from '../utils/media-carousel-utils-stub';
|
|
6
6
|
import { Video, extractYouTubeId } from './features/video';
|
|
7
|
+
import { PlayIcon } from './icons-v2-generated/media-playback/play-icon';
|
|
7
8
|
|
|
8
9
|
// Navigation icons
|
|
9
10
|
const ChevronLeftIcon = () => (
|
|
@@ -215,9 +216,7 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
215
216
|
{(item.type === 'video' || item.type === 'youtube') && (
|
|
216
217
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
217
218
|
<div className="bg-black/70 rounded-full p-1">
|
|
218
|
-
<
|
|
219
|
-
<path d="M8 5v14l11-7z"/>
|
|
220
|
-
</svg>
|
|
219
|
+
<PlayIcon size={12} color="white" />
|
|
221
220
|
</div>
|
|
222
221
|
</div>
|
|
223
222
|
)}
|
package/src/types/case-study.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface CaseStudy {
|
|
|
25
25
|
|
|
26
26
|
// Testimonial video (text testimonials come from MSP profile)
|
|
27
27
|
testimonial_video_url: string | null // YouTube URL (preferred when both exist)
|
|
28
|
-
|
|
28
|
+
main_video_url: string | null // Uploaded video file URL (fallback when no YouTube)
|
|
29
29
|
main_video_thumbnail?: string | null // Manual poster image URL for testimonial video. Standardized name matches main_video_thumbnail on customer_interviews/webinars/podcasts/investor_updates/product_releases per lib/data/entity-video-utils.ts ENTITY_FIELD_CONFIG. Optional for backward-compat with existing literal CaseStudy constructions across hub + external lib consumers.
|
|
30
30
|
|
|
31
31
|
// Video enhancement fields
|
|
@@ -80,7 +80,7 @@ export interface CreateCaseStudyData {
|
|
|
80
80
|
solution?: string
|
|
81
81
|
results?: string
|
|
82
82
|
testimonial_video_url?: string // YouTube URL
|
|
83
|
-
|
|
83
|
+
main_video_url?: string // Uploaded video file URL
|
|
84
84
|
main_video_thumbnail?: string | null // Manual poster image URL for testimonial video (standardized name across all video-bearing entities). Nullable so admin form can explicitly clear a stale poster from the DB when the testimonial video is removed or the source is switched to YouTube.
|
|
85
85
|
// Video enhancement fields
|
|
86
86
|
video_source_type?: 'youtube' | 'uploaded' // Deprecated
|
package/src/types/supabase.ts
CHANGED
|
@@ -292,7 +292,7 @@ export type Database = {
|
|
|
292
292
|
description: string | null
|
|
293
293
|
cover_url: string | null
|
|
294
294
|
audio_url: string | null
|
|
295
|
-
|
|
295
|
+
main_video_url: string | null
|
|
296
296
|
media_type: string | null
|
|
297
297
|
duration_seconds: number | null
|
|
298
298
|
status: string | null
|
|
@@ -313,7 +313,7 @@ export type Database = {
|
|
|
313
313
|
description?: string | null
|
|
314
314
|
cover_url?: string | null
|
|
315
315
|
audio_url?: string | null
|
|
316
|
-
|
|
316
|
+
main_video_url?: string | null
|
|
317
317
|
media_type?: string | null
|
|
318
318
|
duration_seconds?: number | null
|
|
319
319
|
status?: string | null
|
|
@@ -334,7 +334,7 @@ export type Database = {
|
|
|
334
334
|
description?: string | null
|
|
335
335
|
cover_url?: string | null
|
|
336
336
|
audio_url?: string | null
|
|
337
|
-
|
|
337
|
+
main_video_url?: string | null
|
|
338
338
|
media_type?: string | null
|
|
339
339
|
duration_seconds?: number | null
|
|
340
340
|
status?: string | null
|
|
@@ -368,7 +368,7 @@ export type Database = {
|
|
|
368
368
|
end_at: string | null
|
|
369
369
|
timezone: string | null
|
|
370
370
|
registration_url: string | null
|
|
371
|
-
|
|
371
|
+
main_video_url: string | null
|
|
372
372
|
hosts: Json | null
|
|
373
373
|
platform_id: string
|
|
374
374
|
is_deleted: boolean | null
|
|
@@ -386,7 +386,7 @@ export type Database = {
|
|
|
386
386
|
end_at?: string | null
|
|
387
387
|
timezone?: string | null
|
|
388
388
|
registration_url?: string | null
|
|
389
|
-
|
|
389
|
+
main_video_url?: string | null
|
|
390
390
|
hosts?: Json | null
|
|
391
391
|
platform_id: string
|
|
392
392
|
is_deleted?: boolean | null
|
|
@@ -404,7 +404,7 @@ export type Database = {
|
|
|
404
404
|
end_at?: string | null
|
|
405
405
|
timezone?: string | null
|
|
406
406
|
registration_url?: string | null
|
|
407
|
-
|
|
407
|
+
main_video_url?: string | null
|
|
408
408
|
hosts?: Json | null
|
|
409
409
|
platform_id?: string
|
|
410
410
|
is_deleted?: boolean | null
|