@flamingo-stack/openframe-frontend-core 0.0.551 → 0.0.552-snapshot.20260817220121

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.
Files changed (150) hide show
  1. package/dist/{chunk-2YLMNXLB.js → chunk-25BXB4SH.js} +4 -4
  2. package/dist/{chunk-TXFJFO4A.js → chunk-3OBWO6N6.js} +2 -2
  3. package/dist/{chunk-NRTKEPUX.cjs → chunk-6WA3RI55.cjs} +18 -18
  4. package/dist/{chunk-NRTKEPUX.cjs.map → chunk-6WA3RI55.cjs.map} +1 -1
  5. package/dist/{chunk-MEY3PYOL.cjs → chunk-B5N2IG4Z.cjs} +7 -7
  6. package/dist/{chunk-MEY3PYOL.cjs.map → chunk-B5N2IG4Z.cjs.map} +1 -1
  7. package/dist/{chunk-EZIKNMYV.js → chunk-B7TI3RYG.js} +2 -2
  8. package/dist/{chunk-SWDTM5O6.js → chunk-BUNDMGAD.js} +7 -7
  9. package/dist/{chunk-7OPWSWEJ.cjs → chunk-CEAAOVCV.cjs} +1 -1
  10. package/dist/{chunk-7OPWSWEJ.cjs.map → chunk-CEAAOVCV.cjs.map} +1 -1
  11. package/dist/{chunk-H4RHXJHF.js → chunk-CGIZ4UGO.js} +2 -2
  12. package/dist/{chunk-YA5JDHWE.cjs → chunk-EGPI3SFI.cjs} +101 -101
  13. package/dist/{chunk-YA5JDHWE.cjs.map → chunk-EGPI3SFI.cjs.map} +1 -1
  14. package/dist/{chunk-O24VSDVN.cjs → chunk-LL6EPMAD.cjs} +42 -42
  15. package/dist/{chunk-O24VSDVN.cjs.map → chunk-LL6EPMAD.cjs.map} +1 -1
  16. package/dist/{chunk-YEXTFNVE.js → chunk-LZGHXN2E.js} +10 -10
  17. package/dist/chunk-LZGHXN2E.js.map +1 -0
  18. package/dist/{chunk-EFNDA2CG.js → chunk-MSNTPZJ3.js} +3 -3
  19. package/dist/{chunk-NVK7YZHN.js → chunk-N22NG622.js} +2 -2
  20. package/dist/{chunk-V3HNIPTE.cjs → chunk-NFQEDT43.cjs} +23 -23
  21. package/dist/{chunk-V3HNIPTE.cjs.map → chunk-NFQEDT43.cjs.map} +1 -1
  22. package/dist/{chunk-AK4LW6PF.cjs → chunk-OI56TF4Y.cjs} +42 -42
  23. package/dist/{chunk-AK4LW6PF.cjs.map → chunk-OI56TF4Y.cjs.map} +1 -1
  24. package/dist/{chunk-LQPHB2YF.js → chunk-OR67WC32.js} +2 -2
  25. package/dist/{chunk-H4OIDXP4.cjs → chunk-PWWMA3EJ.cjs} +36 -36
  26. package/dist/{chunk-H4OIDXP4.cjs.map → chunk-PWWMA3EJ.cjs.map} +1 -1
  27. package/dist/{chunk-4QYUNDS3.cjs → chunk-Q6RC3YGW.cjs} +3 -3
  28. package/dist/{chunk-4QYUNDS3.cjs.map → chunk-Q6RC3YGW.cjs.map} +1 -1
  29. package/dist/{chunk-IFH47QXW.js → chunk-QOS27D5S.js} +3 -3
  30. package/dist/{chunk-5RJUHUCI.cjs → chunk-QTO3WYB2.cjs} +5 -5
  31. package/dist/{chunk-5RJUHUCI.cjs.map → chunk-QTO3WYB2.cjs.map} +1 -1
  32. package/dist/{chunk-ZDWWXME5.cjs → chunk-S7ATADRE.cjs} +14 -14
  33. package/dist/{chunk-ZDWWXME5.cjs.map → chunk-S7ATADRE.cjs.map} +1 -1
  34. package/dist/{chunk-MKDL3S4U.cjs → chunk-T7UUDSDW.cjs} +3 -3
  35. package/dist/{chunk-MKDL3S4U.cjs.map → chunk-T7UUDSDW.cjs.map} +1 -1
  36. package/dist/{chunk-MVK7SNVO.js → chunk-UVAB3XAB.js} +6 -6
  37. package/dist/{chunk-Y2HKHGEW.js → chunk-UWY722RH.js} +1 -1
  38. package/dist/{chunk-Y2HKHGEW.js.map → chunk-UWY722RH.js.map} +1 -1
  39. package/dist/{chunk-ZYQAFJLF.cjs → chunk-V643QZFI.cjs} +4 -4
  40. package/dist/{chunk-ZYQAFJLF.cjs.map → chunk-V643QZFI.cjs.map} +1 -1
  41. package/dist/{chunk-4A736Z6S.js → chunk-VGLDA77S.js} +2 -2
  42. package/dist/{chunk-RJFD55SK.js → chunk-VYVOER73.js} +4 -4
  43. package/dist/{chunk-V5PDJFZS.js → chunk-WLQER7GC.js} +18 -13
  44. package/dist/chunk-WLQER7GC.js.map +1 -0
  45. package/dist/{chunk-T2JKOG6K.cjs → chunk-XBKBBKPR.cjs} +7 -7
  46. package/dist/{chunk-T2JKOG6K.cjs.map → chunk-XBKBBKPR.cjs.map} +1 -1
  47. package/dist/{chunk-PPPIBTCO.cjs → chunk-XPRFVYES.cjs} +14 -14
  48. package/dist/{chunk-PPPIBTCO.cjs.map → chunk-XPRFVYES.cjs.map} +1 -1
  49. package/dist/{chunk-72ZPQTPN.cjs → chunk-Y2WE2GOL.cjs} +96 -91
  50. package/dist/chunk-Y2WE2GOL.cjs.map +1 -0
  51. package/dist/{chunk-OMSEQDCU.js → chunk-YI2735PG.js} +3 -3
  52. package/dist/{chunk-CPADTPX4.cjs → chunk-ZGQZLUO6.cjs} +1035 -940
  53. package/dist/chunk-ZGQZLUO6.cjs.map +1 -0
  54. package/dist/{chunk-JI4JR3KC.js → chunk-ZS7VC5SX.js} +387 -292
  55. package/dist/chunk-ZS7VC5SX.js.map +1 -0
  56. package/dist/components/case-studies/index.cjs +10 -10
  57. package/dist/components/case-studies/index.js +4 -4
  58. package/dist/components/chat/entity-cards/chat-video-entity-card.d.ts.map +1 -1
  59. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  60. package/dist/components/chat/index.cjs +4 -4
  61. package/dist/components/chat/index.js +3 -3
  62. package/dist/components/contact/index.cjs +5 -5
  63. package/dist/components/contact/index.js +4 -4
  64. package/dist/components/docs/index.cjs +7 -7
  65. package/dist/components/docs/index.js +6 -6
  66. package/dist/components/embeds/index.cjs +5 -5
  67. package/dist/components/embeds/index.js +4 -4
  68. package/dist/components/faq/index.cjs +6 -6
  69. package/dist/components/faq/index.js +5 -5
  70. package/dist/components/features/captions-url.cjs +39 -1
  71. package/dist/components/features/captions-url.cjs.map +1 -1
  72. package/dist/components/features/captions-url.d.ts +59 -0
  73. package/dist/components/features/captions-url.d.ts.map +1 -1
  74. package/dist/components/features/captions-url.js +35 -2
  75. package/dist/components/features/captions-url.js.map +1 -1
  76. package/dist/components/features/entity-video-section.d.ts +12 -1
  77. package/dist/components/features/entity-video-section.d.ts.map +1 -1
  78. package/dist/components/features/index.cjs +16 -4
  79. package/dist/components/features/index.cjs.map +1 -1
  80. package/dist/components/features/index.d.ts +1 -0
  81. package/dist/components/features/index.d.ts.map +1 -1
  82. package/dist/components/features/index.js +15 -3
  83. package/dist/components/features/use-captions.d.ts +11 -0
  84. package/dist/components/features/use-captions.d.ts.map +1 -0
  85. package/dist/components/features/use-ios-native-video-fullscreen.d.ts +4 -0
  86. package/dist/components/features/use-ios-native-video-fullscreen.d.ts.map +1 -0
  87. package/dist/components/features/use-walkthrough-video.d.ts +4 -1
  88. package/dist/components/features/use-walkthrough-video.d.ts.map +1 -1
  89. package/dist/components/features/video.d.ts.map +1 -1
  90. package/dist/components/help-center-pages/index.cjs +25 -25
  91. package/dist/components/help-center-pages/index.js +16 -16
  92. package/dist/components/index.cjs +153 -141
  93. package/dist/components/index.cjs.map +1 -1
  94. package/dist/components/index.js +26 -14
  95. package/dist/components/index.js.map +1 -1
  96. package/dist/components/meeting-scheduler/index.cjs +35 -35
  97. package/dist/components/meeting-scheduler/index.js +5 -5
  98. package/dist/components/navigation/index.cjs +4 -4
  99. package/dist/components/navigation/index.js +3 -3
  100. package/dist/components/onboarding-guides/index.cjs +8 -8
  101. package/dist/components/onboarding-guides/index.js +7 -7
  102. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  103. package/dist/components/related-content/index.cjs +6 -6
  104. package/dist/components/related-content/index.js +5 -5
  105. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  106. package/dist/components/tickets/index.cjs +8 -8
  107. package/dist/components/tickets/index.js +7 -7
  108. package/dist/components/ui/index.cjs +4 -4
  109. package/dist/components/ui/index.js +3 -3
  110. package/dist/contexts/chat-runtime-context.d.ts +8 -0
  111. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  112. package/dist/contexts/index.cjs +2 -2
  113. package/dist/contexts/index.js +1 -1
  114. package/dist/hooks/index.cjs +3 -3
  115. package/dist/hooks/index.js +2 -2
  116. package/dist/index.cjs +16 -4
  117. package/dist/index.cjs.map +1 -1
  118. package/dist/index.js +15 -3
  119. package/package.json +1 -1
  120. package/src/components/chat/entity-cards/chat-video-entity-card.tsx +4 -0
  121. package/src/components/chat/entity-cards/dispatch.tsx +2 -0
  122. package/src/components/features/__tests__/use-ios-native-video-fullscreen.test.tsx +116 -0
  123. package/src/components/features/captions-url.ts +115 -1
  124. package/src/components/features/entity-video-section.tsx +22 -2
  125. package/src/components/features/index.ts +1 -0
  126. package/src/components/features/use-captions.ts +46 -0
  127. package/src/components/features/use-ios-native-video-fullscreen.ts +89 -0
  128. package/src/components/features/use-walkthrough-video.ts +17 -5
  129. package/src/components/features/video.tsx +5 -0
  130. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +5 -3
  131. package/src/components/shared/product-release/release-detail-page.tsx +14 -4
  132. package/src/contexts/chat-runtime-context.tsx +8 -0
  133. package/dist/chunk-72ZPQTPN.cjs.map +0 -1
  134. package/dist/chunk-CPADTPX4.cjs.map +0 -1
  135. package/dist/chunk-JI4JR3KC.js.map +0 -1
  136. package/dist/chunk-V5PDJFZS.js.map +0 -1
  137. package/dist/chunk-YEXTFNVE.js.map +0 -1
  138. /package/dist/{chunk-2YLMNXLB.js.map → chunk-25BXB4SH.js.map} +0 -0
  139. /package/dist/{chunk-TXFJFO4A.js.map → chunk-3OBWO6N6.js.map} +0 -0
  140. /package/dist/{chunk-EZIKNMYV.js.map → chunk-B7TI3RYG.js.map} +0 -0
  141. /package/dist/{chunk-SWDTM5O6.js.map → chunk-BUNDMGAD.js.map} +0 -0
  142. /package/dist/{chunk-H4RHXJHF.js.map → chunk-CGIZ4UGO.js.map} +0 -0
  143. /package/dist/{chunk-EFNDA2CG.js.map → chunk-MSNTPZJ3.js.map} +0 -0
  144. /package/dist/{chunk-NVK7YZHN.js.map → chunk-N22NG622.js.map} +0 -0
  145. /package/dist/{chunk-LQPHB2YF.js.map → chunk-OR67WC32.js.map} +0 -0
  146. /package/dist/{chunk-IFH47QXW.js.map → chunk-QOS27D5S.js.map} +0 -0
  147. /package/dist/{chunk-MVK7SNVO.js.map → chunk-UVAB3XAB.js.map} +0 -0
  148. /package/dist/{chunk-4A736Z6S.js.map → chunk-VGLDA77S.js.map} +0 -0
  149. /package/dist/{chunk-RJFD55SK.js.map → chunk-VYVOER73.js.map} +0 -0
  150. /package/dist/{chunk-OMSEQDCU.js.map → chunk-YI2735PG.js.map} +0 -0
package/dist/index.js CHANGED
@@ -702,6 +702,7 @@ import {
702
702
  brandMarkDataUri,
703
703
  buildAnchorProps,
704
704
  buildAutoContinuationDirective,
705
+ buildCaptionsUrl,
705
706
  buildChatAttachmentViewUrl,
706
707
  buildDefaultHref,
707
708
  buildDiscussAddendum,
@@ -717,6 +718,7 @@ import {
717
718
  buildSuggestionUrl,
718
719
  buildTicketOpenHref,
719
720
  canonicalContentRefType,
721
+ captionsEntityTypeForDocType,
720
722
  captureFirstTouchAttribution,
721
723
  cardAwareUrlTransform,
722
724
  changelogLabels,
@@ -848,6 +850,8 @@ import {
848
850
  getDocAncestorNodeIds,
849
851
  getDocSourceDefaultPath,
850
852
  getDynamicIcon,
853
+ getEntityCaptionUrls,
854
+ getEntityCaptionUrlsById,
851
855
  getExpandedRowModel,
852
856
  getFacetedRowModel,
853
857
  getFacetedUniqueValues,
@@ -954,6 +958,7 @@ import {
954
958
  readDismissCookie,
955
959
  readFetchedCardTitle,
956
960
  readLeadingDecisionFrame,
961
+ rebaseCaptionsUrl,
957
962
  rehypeStripUnsafe,
958
963
  releaseStatusOptions,
959
964
  releaseTypeOptions,
@@ -1012,6 +1017,7 @@ import {
1012
1017
  urlPathLooksLikeSvg,
1013
1018
  useAppLayoutDrawerContainer,
1014
1019
  useBoardCollapse,
1020
+ useCaptions,
1015
1021
  useChat,
1016
1022
  useChatAttachmentImageGallery,
1017
1023
  useChatAttachments,
@@ -1064,7 +1070,7 @@ import {
1064
1070
  withFirstTouchAttribution,
1065
1071
  withWalkthroughOpenParam,
1066
1072
  writeDismissCookie
1067
- } from "./chunk-JI4JR3KC.js";
1073
+ } from "./chunk-ZS7VC5SX.js";
1068
1074
  import "./chunk-7EUIW46X.js";
1069
1075
  import "./chunk-LXC6P2EO.js";
1070
1076
  import "./chunk-EL5YVPD5.js";
@@ -1200,7 +1206,7 @@ import {
1200
1206
  validateSchema,
1201
1207
  validateVariables,
1202
1208
  variablesToUrlParams
1203
- } from "./chunk-4A736Z6S.js";
1209
+ } from "./chunk-VGLDA77S.js";
1204
1210
  import "./chunk-ZJJERZUA.js";
1205
1211
  import {
1206
1212
  AboutIcon,
@@ -1361,7 +1367,7 @@ import {
1361
1367
  PLAY_ICON_PATH
1362
1368
  } from "./chunk-JMZH4KMJ.js";
1363
1369
  import "./chunk-W72U7OU7.js";
1364
- import "./chunk-Y2HKHGEW.js";
1370
+ import "./chunk-UWY722RH.js";
1365
1371
  import {
1366
1372
  Checkbox,
1367
1373
  DropdownMenu,
@@ -2307,6 +2313,7 @@ export {
2307
2313
  breakpoints,
2308
2314
  buildAnchorProps,
2309
2315
  buildAutoContinuationDirective,
2316
+ buildCaptionsUrl,
2310
2317
  buildChatAttachmentViewUrl,
2311
2318
  buildDefaultHref,
2312
2319
  buildDiscussAddendum,
@@ -2324,6 +2331,7 @@ export {
2324
2331
  buildTicketOpenHref,
2325
2332
  buttonVariants,
2326
2333
  canonicalContentRefType,
2334
+ captionsEntityTypeForDocType,
2327
2335
  captureFirstTouchAttribution,
2328
2336
  cardAwareUrlTransform,
2329
2337
  changelogLabels,
@@ -2483,6 +2491,8 @@ export {
2483
2491
  getDocSourceDefaultPath,
2484
2492
  getDynamicIcon,
2485
2493
  getEmbedProxyAuth,
2494
+ getEntityCaptionUrls,
2495
+ getEntityCaptionUrlsById,
2486
2496
  getExpandedRowModel,
2487
2497
  getFacetedRowModel,
2488
2498
  getFacetedUniqueValues,
@@ -2618,6 +2628,7 @@ export {
2618
2628
  readDismissCookie,
2619
2629
  readFetchedCardTitle,
2620
2630
  readLeadingDecisionFrame,
2631
+ rebaseCaptionsUrl,
2621
2632
  rehypeStripUnsafe,
2622
2633
  releaseStatusOptions,
2623
2634
  releaseTypeOptions,
@@ -2700,6 +2711,7 @@ export {
2700
2711
  useAutoLimitTags,
2701
2712
  useBatchImages,
2702
2713
  useBoardCollapse,
2714
+ useCaptions,
2703
2715
  useChat,
2704
2716
  useChatAttachmentImageGallery,
2705
2717
  useChatAttachments,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.551",
3
+ "version": "0.0.552-snapshot.20260817220121",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React from 'react'
4
4
  import { EntityVideoSection } from '../../features/entity-video-section'
5
+ import { captionsEntityTypeForDocType } from '../../features/captions-url'
5
6
  import type { ChatRef } from '../chat-ref.types'
6
7
 
7
8
  /**
@@ -55,6 +56,9 @@ export function ChatVideoEntityCard({
55
56
  highlightVideoUrl={highlightUrl}
56
57
  highlightVideoThumbnail={highlightPoster}
57
58
  mainVideoPoster={poster}
59
+ // Captions derive INSIDE the section from entity identity via the
60
+ // runtime's captions base — no caption data on the wire at all.
61
+ captionsEntity={{ type: captionsEntityTypeForDocType(chatRef.type), id: chatRef.id }}
58
62
  title={chatRef.title}
59
63
  // Intentionally omitted for chat density:
60
64
  // videoSummary — assistant text above already covers it.
@@ -418,10 +418,12 @@ function itemVideoMetadata(item: any): Record<string, string> | null {
418
418
  item?.highlight_video_thumbnail,
419
419
  item?.featured_image,
420
420
  )
421
+ const highlightVideoPoster = pick(m.highlightVideoPoster, item?.highlight_video_thumbnail)
421
422
  if (videoUrl) out.videoUrl = videoUrl
422
423
  if (youtubeUrl) out.youtubeUrl = youtubeUrl
423
424
  if (highlightVideoUrl) out.highlightVideoUrl = highlightVideoUrl
424
425
  if (videoPoster) out.videoPoster = videoPoster
426
+ if (highlightVideoPoster) out.highlightVideoPoster = highlightVideoPoster
425
427
  return out.videoUrl || out.youtubeUrl || out.highlightVideoUrl ? out : null
426
428
  }
427
429
 
@@ -0,0 +1,116 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { renderHook } from '@testing-library/react';
4
+ import { useIosNativeVideoFullscreen } from '../use-ios-native-video-fullscreen';
5
+
6
+ /**
7
+ * The composed tree media-chrome dispatches through: the request starts at the
8
+ * fullscreen button inside `<media-controller>`, inside the player host's shadow
9
+ * root. The controller listener stands in for the handler that would service the
10
+ * request as element fullscreen when nothing claims it first.
11
+ */
12
+ function mountPlayer({ supportsFullscreen = true }: { supportsFullscreen?: boolean } = {}) {
13
+ const host = document.createElement('div');
14
+ document.body.append(host);
15
+ const controller = document.createElement('div');
16
+ host.attachShadow({ mode: 'open' }).append(controller);
17
+ const button = document.createElement('button');
18
+ controller.append(button);
19
+
20
+ const webkitEnterFullscreen = vi.fn();
21
+ const video = Object.assign(document.createElement('video'), {
22
+ webkitSupportsFullscreen: supportsFullscreen,
23
+ webkitEnterFullscreen,
24
+ });
25
+ Object.assign(host, { media: { nativeEl: video } });
26
+
27
+ const serviced = vi.fn();
28
+ controller.addEventListener('mediaenterfullscreenrequest', serviced);
29
+
30
+ return {
31
+ playerRef: { current: host as unknown },
32
+ pressFullscreen: () =>
33
+ button.dispatchEvent(new CustomEvent('mediaenterfullscreenrequest', { bubbles: true, composed: true })),
34
+ serviced,
35
+ webkitEnterFullscreen,
36
+ };
37
+ }
38
+
39
+ function setShell(platform: 'ios' | 'android' | null): void {
40
+ if (platform === null) {
41
+ delete (globalThis as { Capacitor?: unknown }).Capacitor;
42
+ return;
43
+ }
44
+ (globalThis as { Capacitor?: unknown }).Capacitor = {
45
+ isNativePlatform: () => true,
46
+ getPlatform: () => platform,
47
+ };
48
+ }
49
+
50
+ afterEach(() => {
51
+ setShell(null);
52
+ document.body.innerHTML = '';
53
+ });
54
+
55
+ describe('useIosNativeVideoFullscreen', () => {
56
+ it('hands the request to the system player instead of the fullscreen API', () => {
57
+ setShell('ios');
58
+ const { playerRef, pressFullscreen, serviced, webkitEnterFullscreen } = mountPlayer();
59
+ renderHook(() => useIosNativeVideoFullscreen(playerRef));
60
+
61
+ pressFullscreen();
62
+
63
+ expect(webkitEnterFullscreen).toHaveBeenCalledTimes(1);
64
+ // Stopped in the capture phase, so element fullscreen is never requested.
65
+ expect(serviced).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('leaves the request to the player off the iOS shell', () => {
69
+ setShell(null);
70
+ const { playerRef, pressFullscreen, serviced, webkitEnterFullscreen } = mountPlayer();
71
+ renderHook(() => useIosNativeVideoFullscreen(playerRef));
72
+
73
+ pressFullscreen();
74
+
75
+ expect(webkitEnterFullscreen).not.toHaveBeenCalled();
76
+ expect(serviced).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('leaves the request to the player on the Android shell', () => {
80
+ setShell('android');
81
+ const { playerRef, pressFullscreen, serviced, webkitEnterFullscreen } = mountPlayer();
82
+ renderHook(() => useIosNativeVideoFullscreen(playerRef));
83
+
84
+ pressFullscreen();
85
+
86
+ expect(webkitEnterFullscreen).not.toHaveBeenCalled();
87
+ expect(serviced).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ // `webkitSupportsFullscreen` stays false until metadata loads — reachable on a
91
+ // Save-Data connection, where preload is 'none'.
92
+ it('leaves the request to the player while the video reports no fullscreen support', () => {
93
+ setShell('ios');
94
+ const { playerRef, pressFullscreen, serviced, webkitEnterFullscreen } = mountPlayer({
95
+ supportsFullscreen: false,
96
+ });
97
+ renderHook(() => useIosNativeVideoFullscreen(playerRef));
98
+
99
+ pressFullscreen();
100
+
101
+ expect(webkitEnterFullscreen).not.toHaveBeenCalled();
102
+ expect(serviced).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ it('stops intercepting once unmounted', () => {
106
+ setShell('ios');
107
+ const { playerRef, pressFullscreen, serviced, webkitEnterFullscreen } = mountPlayer();
108
+ const { unmount } = renderHook(() => useIosNativeVideoFullscreen(playerRef));
109
+
110
+ unmount();
111
+ pressFullscreen();
112
+
113
+ expect(webkitEnterFullscreen).not.toHaveBeenCalled();
114
+ expect(serviced).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -30,5 +30,119 @@ export function getCaptionsUrl(
30
30
  if (!srtContent) return undefined
31
31
  const hash = `${srtContent.length}-${srtContent.slice(0, 8).replace(/\s/g, '')}`
32
32
  const variant = options?.variant ? `&variant=${options.variant}` : ''
33
- return `/api/captions/${entityType}/${entityId}?v=${hash}${variant}`
33
+ return `${DEFAULT_CAPTIONS_PATH}/${entityType}/${entityId}?v=${hash}${variant}`
34
+ }
35
+
36
+ /** The slice of `ChatRuntime.endpoints` this module needs. */
37
+ export interface CaptionsEndpoints {
38
+ /** Base URL prefix for the captions route (plain path base, no query
39
+ * params) — e.g. `/content/api/captions` in a proxied embedder. Unset ⇒
40
+ * the same-origin relative default `/api/captions` (the hub). Wired by
41
+ * hosts exactly like every other endpoint on `ChatRuntime.endpoints`. */
42
+ captionsUrlPrefix?: string
43
+ }
44
+
45
+ /** Same-origin default — for hosts that serve the route themselves (the hub).
46
+ * Cross-origin embedders set `endpoints.captionsUrlPrefix` instead. */
47
+ const DEFAULT_CAPTIONS_PATH = '/api/captions'
48
+
49
+ function resolveCaptionsBase(endpoints?: CaptionsEndpoints | null): string {
50
+ return endpoints?.captionsUrlPrefix || DEFAULT_CAPTIONS_PATH
51
+ }
52
+
53
+ /** Rebase an already-built relative `/api/captions/...` URL (e.g. one the hub
54
+ * API computed server-side) onto the host's captions base. Same-origin hosts
55
+ * (base = default) get the URL back untouched. Non-captions URLs pass through. */
56
+ export function rebaseCaptionsUrl<T extends string | null | undefined>(
57
+ endpoints: CaptionsEndpoints | null | undefined,
58
+ url: T,
59
+ ): T | string {
60
+ if (!url || !url.startsWith(`${DEFAULT_CAPTIONS_PATH}/`)) return url
61
+ const base = resolveCaptionsBase(endpoints)
62
+ if (base === DEFAULT_CAPTIONS_PATH) return url
63
+ return `${base}${url.slice(DEFAULT_CAPTIONS_PATH.length)}`
64
+ }
65
+
66
+ /**
67
+ * The endpoints-aware entry point — `getCaptionsUrl` based on
68
+ * `endpoints.captionsUrlPrefix` (consumers hand over `runtime?.endpoints` and
69
+ * nothing else). Hub/same-origin hosts leave the prefix unset and resolve to
70
+ * the relative default.
71
+ */
72
+ export function buildCaptionsUrl(
73
+ endpoints: CaptionsEndpoints | null | undefined,
74
+ entityType: string,
75
+ entityId: string | number,
76
+ srtContent?: string | null,
77
+ options?: { variant?: 'highlight' },
78
+ ): string | undefined {
79
+ const rel = getCaptionsUrl(entityType, entityId, srtContent, options)
80
+ return rel ? (rebaseCaptionsUrl(endpoints, rel) as string) : undefined
81
+ }
82
+
83
+ /** The two SRT columns every video entity carries — main video + highlight
84
+ * reel. The caption URL shape is fully generic over these
85
+ * (`?variant=highlight` is the only difference), so consumers never derive
86
+ * the two URLs by hand. */
87
+ export interface CaptionSrtFields {
88
+ id: string | number
89
+ srt_content?: string | null
90
+ highlight_srt_content?: string | null
91
+ }
92
+
93
+ export interface EntityCaptionUrls {
94
+ captionsUrl?: string
95
+ highlightCaptionsUrl?: string
96
+ }
97
+
98
+ /**
99
+ * THE one-stop caption derivation for a video entity: hand over the runtime
100
+ * `endpoints` + the entity row and get both `<track>` URLs back, base-resolved
101
+ * for the host (relative on the hub, proxied in embedders). Every view
102
+ * (release, onboarding guide, webinar, podcast, …) calls this once instead of
103
+ * hand-building main + highlight URLs separately.
104
+ */
105
+ export function getEntityCaptionUrls(
106
+ endpoints: CaptionsEndpoints | null | undefined,
107
+ entityType: string,
108
+ entity: CaptionSrtFields | null | undefined,
109
+ ): EntityCaptionUrls {
110
+ if (!entity) return {}
111
+ return {
112
+ captionsUrl: buildCaptionsUrl(endpoints, entityType, entity.id, entity.srt_content),
113
+ highlightCaptionsUrl: buildCaptionsUrl(endpoints, entityType, entity.id, entity.highlight_srt_content, {
114
+ variant: 'highlight',
115
+ }),
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Identity-only variant of `getEntityCaptionUrls` — for surfaces that know the
121
+ * entity's type + id but NOT its SRT columns (chat cards, whose hydration rows
122
+ * deliberately exclude the heavy SRT content). The `/api/captions` route is
123
+ * addressed purely by `<entityType>/<entityId>`, so the URLs derive from
124
+ * identity alone; a video without subtitles costs one silent 404 `<track>`
125
+ * fetch. No `?v=` cache-buster (no content to hash) — the route's own
126
+ * Cache-Control governs freshness.
127
+ */
128
+ export function getEntityCaptionUrlsById(
129
+ endpoints: CaptionsEndpoints | null | undefined,
130
+ entityType: string,
131
+ entityId: string | number,
132
+ ): Required<EntityCaptionUrls> {
133
+ const base = resolveCaptionsBase(endpoints)
134
+ return {
135
+ captionsUrl: `${base}/${entityType}/${entityId}`,
136
+ highlightCaptionsUrl: `${base}/${entityType}/${entityId}?variant=highlight`,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Chat docType → captions-route entity type. Chat documentTypes equal the
142
+ * captions entity types for every video-bearing type except podcast
143
+ * (`podcast` vs `podcast_episode`). Kept here (not per-card) so the alias
144
+ * exists exactly once.
145
+ */
146
+ export function captionsEntityTypeForDocType(docType: string): string {
147
+ return docType === 'podcast' ? 'podcast_episode' : docType
34
148
  }
@@ -9,6 +9,7 @@ import {
9
9
  } from '../ui/tabs';
10
10
  import type { VideoTeaser } from '../../types/video-processing';
11
11
  import { Video } from './video';
12
+ import { useCaptions } from './use-captions';
12
13
  import { VideoBitesStrip, type VideoBiteStripItem } from './video-bites-strip';
13
14
  import { DEFAULT_VIDEO_BITES_TITLE, type VideoBiteStripProfile } from './video-bites-shared';
14
15
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
@@ -77,6 +78,14 @@ export interface EntityVideoSectionProps {
77
78
  * player (`getCaptionsUrl(type, id, highlightSrt, { variant: 'highlight' })`).
78
79
  */
79
80
  highlightCaptionsUrl?: string | null;
81
+ /**
82
+ * Entity identity for caption derivation — the alternative to the explicit
83
+ * URL props above for hosts that don't hold the entity's SRT columns (chat
84
+ * cards). The section derives both `<track>` URLs itself from the runtime's
85
+ * captions base (`getEntityCaptionUrlsById`); explicit `captionsUrl` /
86
+ * `highlightCaptionsUrl` win when also provided.
87
+ */
88
+ captionsEntity?: { type: string; id: string | number } | null;
80
89
  /** LCP hint — when true, the full-video tab's poster eager-loads. */
81
90
  priority?: boolean;
82
91
  }
@@ -99,14 +108,25 @@ export function EntityVideoSection({
99
108
  bitesAutoScroll = true,
100
109
  MarkdownRenderer,
101
110
  srtContent,
102
- captionsUrl,
103
- highlightCaptionsUrl,
111
+ captionsUrl: captionsUrlProp,
112
+ highlightCaptionsUrl: highlightCaptionsUrlProp,
113
+ captionsEntity,
104
114
  priority = false,
105
115
  }: EntityVideoSectionProps) {
116
+ const captions = useCaptions();
106
117
  const hasFullVideo = !!(youtubeUrl || mainVideoUrl);
107
118
  const hasHighlight = !!highlightVideoUrl;
108
119
  const hasVideo = hasFullVideo || hasHighlight;
109
120
 
121
+ // Caption resolution: explicit URL props win (detail pages gate on their
122
+ // entity's SRT columns); otherwise derive from `captionsEntity` identity —
123
+ // the zero-knowledge path for chat cards.
124
+ const derivedCaptions = captionsEntity
125
+ ? captions.forEntityId(captionsEntity.type, captionsEntity.id)
126
+ : null;
127
+ const captionsUrl = captionsUrlProp ?? derivedCaptions?.captionsUrl;
128
+ const highlightCaptionsUrl = highlightCaptionsUrlProp ?? derivedCaptions?.highlightCaptionsUrl;
129
+
110
130
  if (!hasVideo && !videoSummary && (!videoBites || videoBites.length === 0)) {
111
131
  return null;
112
132
  }
@@ -72,6 +72,7 @@ export * from './floating-walkthrough-video'
72
72
  export * from './use-walkthrough-video'
73
73
  export * from './use-video-warmup'
74
74
  export * from './captions-url'
75
+ export * from './use-captions'
75
76
  export * from './video-source-selector'
76
77
  export * from './transcript-summary-editor'
77
78
  export * from './subtitles-editor'
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * useCaptions — THE captions configurer.
5
+ *
6
+ * The ONE place in the lib that reads the chat-runtime endpoints
7
+ * (`endpoints.captionsUrlPrefix`) for caption purposes. Every caption
8
+ * consumer (`EntityVideoSection`, `ReleaseDetailPage`,
9
+ * `OnboardingGuideDetailView`, `useWalkthroughVideo`, …) calls this hook and
10
+ * uses the returned pre-bound helpers — no component reads runtime endpoint
11
+ * values for captions itself.
12
+ *
13
+ * The pure derivation logic lives in `captions-url.ts` (endpoint-agnostic,
14
+ * server-import-safe); this hook only binds it to the ambient runtime.
15
+ */
16
+
17
+ import { useMemo } from 'react'
18
+ import { useChatRuntime } from '../../contexts/chat-runtime-context'
19
+ import {
20
+ getEntityCaptionUrls,
21
+ getEntityCaptionUrlsById,
22
+ rebaseCaptionsUrl,
23
+ type CaptionSrtFields,
24
+ type EntityCaptionUrls,
25
+ } from './captions-url'
26
+
27
+ export interface CaptionsApi {
28
+ /** Both track URLs from an entity row's SRT columns (presence-gated). */
29
+ forEntity: (entityType: string, entity: CaptionSrtFields | null | undefined) => EntityCaptionUrls
30
+ /** Both track URLs from entity identity alone (no SRT knowledge — chat cards). */
31
+ forEntityId: (entityType: string, entityId: string | number) => Required<EntityCaptionUrls>
32
+ /** Rebase an already-built relative `/api/captions/...` URL onto the host base. */
33
+ rebase: <T extends string | null | undefined>(url: T) => T | string
34
+ }
35
+
36
+ export function useCaptions(): CaptionsApi {
37
+ const endpoints = useChatRuntime()?.endpoints ?? null
38
+ return useMemo(
39
+ () => ({
40
+ forEntity: (entityType, entity) => getEntityCaptionUrls(endpoints, entityType, entity),
41
+ forEntityId: (entityType, entityId) => getEntityCaptionUrlsById(endpoints, entityType, entityId),
42
+ rebase: (url) => rebaseCaptionsUrl(endpoints, url),
43
+ }),
44
+ [endpoints],
45
+ )
46
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * iOS app shell only: hand the player's fullscreen control to Apple's own video
5
+ * player instead of the Fullscreen API.
6
+ *
7
+ * This is what media-chrome already does on iPhone Safari — `requestFullscreen`
8
+ * does not exist on `Element.prototype` there, so its `enterFullscreen` falls
9
+ * through to `media.webkitEnterFullscreen()` and the video is handed to the
10
+ * system player. The Capacitor shell turns element fullscreen ON (WKWebView's
11
+ * `isElementFullscreenEnabled`), so media-chrome takes the `requestFullscreen()`
12
+ * branch instead — and WebKit then hands the web view back with its scroll
13
+ * view's `contentInsetAdjustmentBehavior` reset from Capacitor's `.never` to
14
+ * `.automatic` and its own `safeAreaInsets` stranded at the fullscreen
15
+ * container's values. The app comes back from a video with a doubled top band
16
+ * and dead space above the home indicator until a rotation, and the shell can
17
+ * only paper over it afterwards (`MainViewController` KVOs `fullscreenState` to
18
+ * restore the behavior). Never entering element fullscreen is the actual fix.
19
+ *
20
+ * The takeover is an event interception, not a monkey-patch: the fullscreen
21
+ * button dispatches `mediaenterfullscreenrequest` (`composed`, `bubbles`) from
22
+ * inside the player's shadow trees, so a CAPTURE listener on the `<mux-player>`
23
+ * host sees it before the `<media-controller>` that would service it, and
24
+ * `stopImmediatePropagation()` is enough to claim the request. Which element
25
+ * media-chrome would have passed to `requestFullscreen` never enters into it.
26
+ *
27
+ * The player's own fullscreen STATE stays correct for free: the system player
28
+ * fires `webkitbeginfullscreen` / `webkitendfullscreen` on the `<video>`,
29
+ * `mux-video` re-dispatches both, and media-chrome's state mediator already
30
+ * listens for them.
31
+ *
32
+ * Anything the takeover cannot service falls through to media-chrome untouched:
33
+ * no `<video>` yet, or `webkitSupportsFullscreen` still false — it stays false
34
+ * until metadata has loaded, which is reachable on a Save-Data connection where
35
+ * preload is `'none'`.
36
+ */
37
+
38
+ import { useEffect } from 'react';
39
+
40
+ /** media-chrome's `MediaUIEvents.MEDIA_ENTER_FULLSCREEN_REQUEST`. Spelled out
41
+ * rather than imported: media-chrome is a transitive dep of the Mux player, not
42
+ * a declared one. */
43
+ const ENTER_FULLSCREEN_REQUEST = 'mediaenterfullscreenrequest';
44
+
45
+ /** The two `HTMLVideoElement` members WebKit adds for its own video-fullscreen
46
+ * presentation — absent from the DOM lib, and absent at runtime off Apple. */
47
+ interface NativeFullscreenVideo extends HTMLVideoElement {
48
+ webkitSupportsFullscreen?: boolean;
49
+ webkitEnterFullscreen?: () => void;
50
+ }
51
+
52
+ interface CapacitorGlobal {
53
+ isNativePlatform?: () => boolean;
54
+ getPlatform?: () => string;
55
+ }
56
+
57
+ /** Gated on the shell, not on the platform: element fullscreen behaves fine in
58
+ * Safari (iPad) and on the desktop shell, where taking it away would only cost
59
+ * the player its chrome in fullscreen. */
60
+ function isIosAppShell(): boolean {
61
+ const capacitor = (globalThis as { Capacitor?: CapacitorGlobal }).Capacitor;
62
+ return capacitor?.isNativePlatform?.() === true && capacitor.getPlatform?.() === 'ios';
63
+ }
64
+
65
+ /** The real `<video>` behind `<mux-player>`: `player.media` is the `<mux-video>`
66
+ * custom element and `nativeEl` is the element WebKit presents. */
67
+ function nativeVideoElement(player: EventTarget): NativeFullscreenVideo | null {
68
+ const media = (player as { media?: { nativeEl?: unknown } | null }).media;
69
+ const element = media?.nativeEl ?? media;
70
+ return element instanceof HTMLVideoElement ? element : null;
71
+ }
72
+
73
+ export function useIosNativeVideoFullscreen(playerRef: { current: unknown }): void {
74
+ useEffect(() => {
75
+ if (!isIosAppShell()) return;
76
+ const player = playerRef.current;
77
+ if (!(player instanceof EventTarget)) return;
78
+
79
+ const takeOver = (event: Event) => {
80
+ const video = nativeVideoElement(player);
81
+ if (!video?.webkitSupportsFullscreen || typeof video.webkitEnterFullscreen !== 'function') return;
82
+ event.stopImmediatePropagation();
83
+ video.webkitEnterFullscreen();
84
+ };
85
+
86
+ player.addEventListener(ENTER_FULLSCREEN_REQUEST, takeOver, true);
87
+ return () => player.removeEventListener(ENTER_FULLSCREEN_REQUEST, takeOver, true);
88
+ }, [playerRef]);
89
+ }
@@ -6,8 +6,11 @@
6
6
  * SSOT for fetching + shaping the widget's data on the CLIENT (any embedder
7
7
  * reuses it). The public endpoint returns the RAW body `{ walkthroughVideo }`
8
8
  * (no {success,data} wrapper). The video's `captionsUrl` is a RELATIVE
9
- * `/api/captions/...` path; pass `transformCaptionsUrl` to route it through a
10
- * proxy (embedders) omit it for same-origin hosts.
9
+ * `/api/captions/...` path; it is rebased automatically onto
10
+ * `ChatRuntime.endpoints.captionsUrlPrefix` (the standard runtime-endpoint
11
+ * wiring) — same-origin hosts leave that unset and get the URL unchanged.
12
+ * `transformCaptionsUrl` remains as an explicit override for hosts outside
13
+ * a runtime provider.
11
14
  *
12
15
  * SSR hosts (the hub) resolve the video server-side and pass it to the widget
13
16
  * directly; they don't need this hook. It exists for client-only embedders.
@@ -15,6 +18,7 @@
15
18
 
16
19
  import { useQuery } from '@tanstack/react-query';
17
20
  import type { WalkthroughVideoData } from './floating-walkthrough-video';
21
+ import { useCaptions } from './use-captions';
18
22
 
19
23
  export interface UseWalkthroughVideoOptions {
20
24
  /** Absolute or proxied URL of the public GET route. */
@@ -22,7 +26,10 @@ export interface UseWalkthroughVideoOptions {
22
26
  /** SSR/initial seed to avoid a loading flash. */
23
27
  initialData?: WalkthroughVideoData | null;
24
28
  enabled?: boolean;
25
- /** Rewrite the RELATIVE captionsUrl (e.g. prefix a `/content` proxy base). */
29
+ /** Rewrite the RELATIVE captionsUrl (e.g. prefix a `/content` proxy base).
30
+ * OPTIONAL override — when unset, the URL is rebased onto
31
+ * `ChatRuntime.endpoints.captionsUrlPrefix` (see `rebaseCaptionsUrl`), so
32
+ * embedders inside a runtime provider need no wiring here at all. */
26
33
  transformCaptionsUrl?: (relativeUrl: string) => string;
27
34
  }
28
35
 
@@ -33,6 +40,7 @@ export interface UseWalkthroughVideoResult {
33
40
 
34
41
  export function useWalkthroughVideo(opts: UseWalkthroughVideoOptions): UseWalkthroughVideoResult {
35
42
  const { endpoint, initialData, enabled = true, transformCaptionsUrl } = opts;
43
+ const captions = useCaptions();
36
44
 
37
45
  const query = useQuery<WalkthroughVideoData | null>({
38
46
  queryKey: ['walkthrough-video', endpoint],
@@ -59,10 +67,14 @@ export function useWalkthroughVideo(opts: UseWalkthroughVideoOptions): UseWalkth
59
67
  // rewrite here (not in queryFn) means a second consumer of the same
60
68
  // endpoint can't inherit this observer's proxied captionsUrl from the cache.
61
69
  select: (video) => {
62
- if (video?.captionsUrl && transformCaptionsUrl && video.captionsUrl.startsWith('/')) {
70
+ if (!video?.captionsUrl) return video;
71
+ if (transformCaptionsUrl && video.captionsUrl.startsWith('/')) {
63
72
  return { ...video, captionsUrl: transformCaptionsUrl(video.captionsUrl) };
64
73
  }
65
- return video;
74
+ // Default: rebase onto the configured captions base. Same-origin hosts
75
+ // leave it unset and get the URL back unchanged — a no-op for them.
76
+ const rebased = captions.rebase(video.captionsUrl);
77
+ return rebased === video.captionsUrl ? video : { ...video, captionsUrl: rebased as string };
66
78
  },
67
79
  });
68
80
 
@@ -34,6 +34,7 @@ import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, us
34
34
  import MuxPlayer from '@mux/mux-player-react';
35
35
  import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
36
36
  import { fetchPriorityProp } from '../../utils/fetch-priority';
37
+ import { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';
37
38
  import { saveDataEnabled } from './use-video-warmup';
38
39
 
39
40
  // =============================================================================
@@ -938,6 +939,10 @@ function FilePlayer({
938
939
  return () => { try { el.removeEventListener?.('ended', handler); } catch { /* ignore */ } };
939
940
  }, []);
940
941
 
942
+ // In the iOS shell the fullscreen control goes to Apple's video player rather
943
+ // than to element fullscreen, which WebKit exits by breaking the safe areas.
944
+ useIosNativeVideoFullscreen(hoverPlayerRef);
945
+
941
946
  // Imperative handle — snapshot getters + control mutators for handoff.
942
947
  useImperativeHandle(playerHandleRef, (): VideoPlayerHandle => ({
943
948
  getCurrentTime: () => hoverPlayerRef.current?.currentTime ?? 0,