@flamingo-stack/openframe-frontend-core 0.0.182 → 0.0.183-snapshot.20260514223203

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)
@@ -279,7 +280,14 @@ function FilePlayer({
279
280
  playsInline
280
281
  muted={muted}
281
282
  preferCmcd="header"
282
- accentColor="var(--ods-accent)"
283
+ // MuxPlayer's built-in default is `#fa50b5` (Mux brand pink) — when
284
+ // its `--media-accent-color` resolves to nothing the player falls
285
+ // through to that hardcoded pink. The `var(--ods-accent,
286
+ // var(--color-accent-primary))` chain hits the platform-aware
287
+ // ODS token first, then the semantic accent alias if `--ods-accent`
288
+ // is ever undefined on a `data-app-type` we haven't themed yet.
289
+ // NEVER let Mux pink leak onto a non-Flamingo platform.
290
+ accentColor="var(--ods-accent, var(--color-accent-primary))"
283
291
  className={className}
284
292
  // Fill the wrapping aspect-ratio container instead of MuxPlayer's
285
293
  // intrinsic size. Without this, MuxPlayer renders at its default
@@ -338,6 +346,26 @@ interface YouTubeFacadeInnerProps {
338
346
  minimalControls?: boolean;
339
347
  }
340
348
 
349
+ const YT_NOCOOKIE_ORIGIN = 'https://www.youtube-nocookie.com';
350
+
351
+ // YouTube IFrame Player API state codes — documented integers.
352
+ // https://developers.google.com/youtube/iframe_api_reference#Playback_status
353
+ const YT_STATE_ENDED = 0;
354
+ const YT_STATE_PLAYING = 1;
355
+
356
+ // Sub-second delay before we blur the iframe after PLAYING. Zero would
357
+ // cancel YouTube's mount-time "controls visible" intro flash entirely
358
+ // (jarring); ~1s lets the user briefly see playback started, then we
359
+ // kick YouTube's internal idle timer by removing DOM focus from the
360
+ // iframe. Net result: controls fade ~1s after playback begins,
361
+ // matching the user-locked target.
362
+ const YT_PLAYING_BLUR_DELAY_MS = 1000;
363
+
364
+ interface YouTubeInfoDeliveryMessage {
365
+ event?: string;
366
+ info?: { playerState?: number };
367
+ }
368
+
341
369
  function YouTubeFacadeInner({
342
370
  videoId,
343
371
  title,
@@ -346,92 +374,124 @@ function YouTubeFacadeInner({
346
374
  minimalControls,
347
375
  }: YouTubeFacadeInnerProps): React.ReactElement {
348
376
  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).
377
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
378
+
379
+ // Embed URL + poster URLs only change when `videoId` or `minimalControls`
380
+ // do memoize so we don't rebuild URLSearchParams on every render.
381
+ //
382
+ // `enablejsapi=1` opens the postMessage state channel we subscribe to
383
+ // below — without it, YouTube ignores `event:listening` messages and
384
+ // we can't detect PLAYING / ENDED to drive the auto-hide accelerator.
385
+ const { embedUrl, posterJpg, posterWebp } = useMemo(() => {
386
+ const params = new URLSearchParams({
387
+ autoplay: '1',
388
+ rel: '0',
389
+ modestbranding: '1',
390
+ playsinline: '1',
391
+ enablejsapi: '1',
392
+ });
393
+ if (minimalControls) {
394
+ params.set('controls', '0');
395
+ params.set('fs', '0');
396
+ params.set('iv_load_policy', '3');
397
+ params.set('cc_load_policy', '0');
398
+ params.set('disablekb', '1');
399
+ }
400
+ return {
401
+ embedUrl: `${YT_NOCOOKIE_ORIGIN}/embed/${videoId}?${params.toString()}`,
402
+ posterJpg: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
403
+ posterWebp: `https://i.ytimg.com/vi_webp/${videoId}/mqdefault.webp`,
404
+ };
405
+ }, [videoId, minimalControls]);
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // YouTube control-fade accelerator (user-locked target: ~1s).
409
+ //
410
+ // YouTube's native idle timer for the bottom control bar is 5–10s when
411
+ // the iframe holds DOM focus. The IFrame Player API has no public
412
+ // `hideControls` command (full `func` list verified — none expose
413
+ // visibility). The one legal lever from outside the iframe is
414
+ // `iframe.blur()`, which kicks YouTube into its post-focus idle path
415
+ // (~2s minimum).
416
+ //
417
+ // Subscribe to YouTube's state channel via the documented lite-mode
418
+ // postMessage handshake — no full `iframe_api.js` library needed:
419
+ // <https://developers.google.com/youtube/iframe_api_reference>.
382
420
  //
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.
421
+ // - PLAYING (1) arrives once autoplay kicks in. Wait ~1s (so the user
422
+ // briefly sees that the player started), then blur the iframe so
423
+ // YouTube's idle timer fires immediately.
424
+ //
425
+ // - ENDED (0) → tear down the iframe. Playback is already over so
426
+ // there's nothing to interrupt, and removing the iframe kills the
427
+ // residual "More videos" suggestion grid YouTube leaves on screen.
428
+ //
429
+ // PAUSED (2) is deliberately unhandled — the user paused on purpose,
430
+ // leave YouTube's UI alone so they can resume.
431
+ //
432
+ // Playback is NEVER stopped by anything in this facade. Outside-click,
433
+ // Escape, tab-switch — all no-ops. The only state flip is on natural
434
+ // end-of-video (ENDED).
435
+ // ---------------------------------------------------------------------------
386
436
  useEffect(() => {
387
437
  if (!activated) return;
438
+ const iframe = iframeRef.current;
439
+ if (!iframe) return;
388
440
 
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);
441
+ function subscribe() {
442
+ iframe?.contentWindow?.postMessage(
443
+ '{"event":"listening"}',
444
+ YT_NOCOOKIE_ORIGIN,
445
+ );
394
446
  }
395
447
 
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);
448
+ iframe.addEventListener('load', subscribe);
449
+ subscribe();
450
+
451
+ let blurTimer: ReturnType<typeof setTimeout> | null = null;
452
+
453
+ function handleMessage(event: MessageEvent) {
454
+ if (event.origin !== YT_NOCOOKIE_ORIGIN) return;
455
+ if (typeof event.data !== 'string') return;
456
+ let payload: YouTubeInfoDeliveryMessage | null = null;
457
+ try {
458
+ payload = JSON.parse(event.data);
459
+ } catch {
460
+ return;
461
+ }
462
+ if (!payload || payload.event !== 'infoDelivery') return;
463
+ const state = payload.info?.playerState;
464
+ if (typeof state !== 'number') return;
465
+
466
+ if (state === YT_STATE_PLAYING) {
467
+ if (blurTimer !== null) return;
468
+ blurTimer = setTimeout(() => {
469
+ blurTimer = null;
470
+ iframeRef.current?.blur();
471
+ }, YT_PLAYING_BLUR_DELAY_MS);
472
+ return;
473
+ }
474
+ if (state === YT_STATE_ENDED) {
475
+ setActivated(false);
476
+ }
407
477
  }
408
478
 
409
- document.addEventListener('keydown', handleEscape);
410
- return () => document.removeEventListener('keydown', handleEscape);
479
+ window.addEventListener('message', handleMessage);
480
+ return () => {
481
+ iframe.removeEventListener('load', subscribe);
482
+ window.removeEventListener('message', handleMessage);
483
+ if (blurTimer !== null) clearTimeout(blurTimer);
484
+ };
411
485
  }, [activated]);
412
486
 
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
487
  const wrapperClass = `relative w-full ${className ?? ''}`;
429
488
  const wrapperStyle = { paddingBottom: '56.25%' as const };
430
489
 
431
490
  if (activated) {
432
491
  return (
433
- <div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
492
+ <div className={wrapperClass} style={wrapperStyle}>
434
493
  <iframe
494
+ ref={iframeRef}
435
495
  src={embedUrl}
436
496
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
437
497
  allowFullScreen
@@ -443,7 +503,7 @@ function YouTubeFacadeInner({
443
503
  }
444
504
 
445
505
  return (
446
- <div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
506
+ <div className={wrapperClass} style={wrapperStyle}>
447
507
  <button
448
508
  type="button"
449
509
  aria-label={`Play: ${title}`}
@@ -463,9 +523,7 @@ function YouTubeFacadeInner({
463
523
  </picture>
464
524
  <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
525
  <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>
526
+ <PlayIcon size={24} color="currentColor" className="ml-1" />
469
527
  </span>
470
528
  </div>
471
529
  </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
  )}
@@ -173,6 +173,16 @@
173
173
 
174
174
  /* Adaptive Current Color (Platform-Specific) */
175
175
  --ods-current: var(--color-text-primary); /* Default to primary text */
176
+
177
+ /* Adaptive Accent Color (Platform-Specific).
178
+ * Defaults to yellow — matches OpenMSP / OpenFrame brand. Every
179
+ * `[data-app-type="X"]` block below overrides this for its platform.
180
+ * Critical: MuxPlayer reads this via `accentColor="var(--ods-accent)"`
181
+ * and falls back to its built-in `#fa50b5` (pink) if the variable is
182
+ * undefined. Keeping a `:root` default guarantees the video player
183
+ * never paints with Mux's brand color on a platform we haven't
184
+ * explicitly themed yet. */
185
+ --ods-accent: var(--ods-open-yellow-base);
176
186
  }
177
187
 
178
188
 
@@ -196,6 +206,53 @@
196
206
  --ods-accent: var(--ods-open-yellow-base);
197
207
  }
198
208
 
209
+ [data-app-type="openmsp"] {
210
+ /* OpenMSP brand: yellow primary (#FFC008). Matches `openmsp.config.tsx`
211
+ * brandColors.primary. Before this block existed, OpenMSP had no
212
+ * `--ods-accent` defined, so the MuxPlayer video controls inherited
213
+ * its built-in pink (`#fa50b5`) — wrong brand color on every video
214
+ * surface in the OpenMSP platform. */
215
+ --color-accent-primary: var(--ods-open-yellow-base);
216
+ --color-accent-hover: var(--ods-open-yellow-hover);
217
+ --color-accent-active: var(--ods-open-yellow-action);
218
+ --color-accent-focus: var(--ods-open-yellow-base);
219
+ --color-focus-ring: var(--ods-open-yellow-base);
220
+ --color-focus-visible: var(--ods-open-yellow-base);
221
+ --color-link: var(--ods-open-yellow-base);
222
+ --color-link-hover: var(--ods-open-yellow-hover);
223
+ --color-bg: var(--ods-system-greys-background);
224
+ --color-bg-card: var(--ods-system-greys-black);
225
+ --color-bg-hover: var(--ods-system-greys-black-hover);
226
+ --color-bg-active: var(--ods-system-greys-black-action);
227
+
228
+ /* OpenMSP uses yellow as adaptive color */
229
+ --ods-current: var(--ods-open-yellow-base);
230
+ --ods-accent: var(--ods-open-yellow-base);
231
+ }
232
+
233
+ [data-app-type="flamingo-teaser"] {
234
+ /* Flamingo Teaser uses the same Flamingo pink brand as the full
235
+ * Flamingo app — matches `flamingo-teaser.config.tsx`. Without this
236
+ * block the video player would fall back to MuxPlayer's pink default,
237
+ * which is coincidentally close but not the brand pink. */
238
+ --color-accent-primary: var(--ods-flamingo-pink-base);
239
+ --color-accent-hover: var(--ods-flamingo-pink-hover);
240
+ --color-accent-active: var(--ods-flamingo-pink-action);
241
+ --color-accent-focus: var(--ods-flamingo-pink-base);
242
+ --color-focus-ring: var(--ods-flamingo-pink-base);
243
+ --color-focus-visible: var(--ods-flamingo-pink-base);
244
+ --color-link: var(--ods-flamingo-pink-base);
245
+ --color-link-hover: var(--ods-flamingo-pink-hover);
246
+ --color-bg: var(--ods-system-greys-background);
247
+ --color-bg-card: var(--ods-system-greys-black);
248
+ --color-bg-hover: var(--ods-system-greys-black-hover);
249
+ --color-bg-active: var(--ods-system-greys-black-action);
250
+
251
+ /* Flamingo Teaser uses pink as adaptive color */
252
+ --ods-current: var(--ods-flamingo-pink-base);
253
+ --ods-accent: var(--ods-flamingo-pink-base);
254
+ }
255
+
199
256
  [data-app-type="flamingo"] {
200
257
  --color-accent-primary: var(--ods-flamingo-pink-base);
201
258
  --color-accent-hover: var(--ods-flamingo-pink-hover);
@@ -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