@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.
@@ -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
- // 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).
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
- // 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.
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 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);
434
+ function subscribe() {
435
+ iframe?.contentWindow?.postMessage(
436
+ '{"event":"listening"}',
437
+ YT_NOCOOKIE_ORIGIN,
438
+ );
394
439
  }
395
440
 
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);
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
- document.addEventListener('keydown', handleEscape);
410
- return () => document.removeEventListener('keydown', handleEscape);
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 ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
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 ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
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
- <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>
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
- <svg width="12" height="12" fill="white" viewBox="0 0 24 24">
219
- <path d="M8 5v14l11-7z"/>
220
- </svg>
219
+ <PlayIcon size={12} color="white" />
221
220
  </div>
222
221
  </div>
223
222
  )}
@@ -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
- uploaded_video_url: string | null // Uploaded video file URL (fallback when no YouTube)
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
- uploaded_video_url?: string // Uploaded video file URL
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
@@ -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
- video_url: string | null
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
- video_url?: string | null
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
- video_url?: string | null
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
- recording_url: string | null
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
- recording_url?: string | null
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
- recording_url?: string | null
407
+ main_video_url?: string | null
408
408
  hosts?: Json | null
409
409
  platform_id?: string
410
410
  is_deleted?: boolean | null