@flamingo-stack/openframe-frontend-core 0.0.445 → 0.0.446-snapshot.20260717214810

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 (132) hide show
  1. package/dist/{chunk-R6WGCJBB.cjs → chunk-2PNWAAU4.cjs} +73 -73
  2. package/dist/{chunk-R6WGCJBB.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
  3. package/dist/{chunk-H352AZJN.js → chunk-3GBLPPSW.js} +6 -6
  4. package/dist/{chunk-TCEBFMLQ.js → chunk-4ONE3HQP.js} +2 -2
  5. package/dist/{chunk-K4RASNQM.js → chunk-5BJJRPDH.js} +3 -3
  6. package/dist/{chunk-H2DYCT6M.js → chunk-6TAIXSF5.js} +2 -2
  7. package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
  8. package/dist/chunk-AN7MCJTV.cjs.map +1 -0
  9. package/dist/{chunk-7R256DKK.js → chunk-D2NLNBLH.js} +2 -2
  10. package/dist/{chunk-N64VTHZ5.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
  11. package/dist/{chunk-N64VTHZ5.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
  12. package/dist/{chunk-DTLNCOSG.cjs → chunk-EZMM2OMP.cjs} +32 -32
  13. package/dist/{chunk-DTLNCOSG.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
  14. package/dist/{chunk-5FUQTSTT.cjs → chunk-FR6NVZVM.cjs} +18 -18
  15. package/dist/{chunk-5FUQTSTT.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
  16. package/dist/{chunk-Z4N7XLFG.js → chunk-G7QSLGMI.js} +3 -3
  17. package/dist/{chunk-5DWPXK37.cjs → chunk-ITJMUH4Q.cjs} +40 -40
  18. package/dist/{chunk-5DWPXK37.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
  19. package/dist/{chunk-P3FN4NAE.cjs → chunk-KYLOPUIH.cjs} +29 -29
  20. package/dist/{chunk-P3FN4NAE.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
  21. package/dist/{chunk-YCLSV2H6.cjs → chunk-PF6CKZIH.cjs} +5 -5
  22. package/dist/{chunk-YCLSV2H6.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
  23. package/dist/{chunk-QL2EI56X.cjs → chunk-RNXMP7GB.cjs} +1014 -988
  24. package/dist/chunk-RNXMP7GB.cjs.map +1 -0
  25. package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
  26. package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
  27. package/dist/{chunk-RXLMN6B5.cjs → chunk-S7VMUDUB.cjs} +12 -12
  28. package/dist/{chunk-RXLMN6B5.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
  29. package/dist/{chunk-22NSWMI6.js → chunk-SHC5NFOH.js} +3 -3
  30. package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
  31. package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
  32. package/dist/{chunk-3DQ5BFPM.js → chunk-TMZYFYSS.js} +5 -5
  33. package/dist/{chunk-KISQUH33.cjs → chunk-WLU37UZO.cjs} +73 -73
  34. package/dist/{chunk-KISQUH33.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
  35. package/dist/{chunk-FIAXFNAA.js → chunk-WMOF3WQT.js} +5 -5
  36. package/dist/{chunk-MPSA6YAM.cjs → chunk-X4XHW3OV.cjs} +40 -40
  37. package/dist/{chunk-MPSA6YAM.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
  38. package/dist/{chunk-3HYSD7NR.cjs → chunk-XUS75SQW.cjs} +15 -15
  39. package/dist/{chunk-3HYSD7NR.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
  40. package/dist/{chunk-3BHV2QAH.js → chunk-YCSNDUDE.js} +510 -484
  41. package/dist/chunk-YCSNDUDE.js.map +1 -0
  42. package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
  43. package/dist/{chunk-MQUS6BFR.js → chunk-ZQVEB6KT.js} +6 -6
  44. package/dist/{chunk-XPZLIONZ.js → chunk-ZYB3BYBA.js} +4 -4
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/cards-strip.d.ts.map +1 -1
  58. package/dist/components/features/highlight-video-section.d.ts.map +1 -1
  59. package/dist/components/features/index.cjs +5 -3
  60. package/dist/components/features/index.cjs.map +1 -1
  61. package/dist/components/features/index.js +4 -2
  62. package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
  63. package/dist/components/features/mux-origins.cjs +14 -0
  64. package/dist/components/features/mux-origins.cjs.map +1 -1
  65. package/dist/components/features/mux-origins.d.ts +26 -2
  66. package/dist/components/features/mux-origins.d.ts.map +1 -1
  67. package/dist/components/features/mux-origins.js +14 -1
  68. package/dist/components/features/mux-origins.js.map +1 -1
  69. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  70. package/dist/components/features/release-media-manager.d.ts.map +1 -1
  71. package/dist/components/features/use-video-warmup.d.ts +12 -0
  72. package/dist/components/features/use-video-warmup.d.ts.map +1 -1
  73. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  74. package/dist/components/features/video-source-selector.d.ts +1 -11
  75. package/dist/components/features/video-source-selector.d.ts.map +1 -1
  76. package/dist/components/features/video.d.ts +14 -1
  77. package/dist/components/features/video.d.ts.map +1 -1
  78. package/dist/components/help-center-pages/index.cjs +22 -22
  79. package/dist/components/help-center-pages/index.js +13 -13
  80. package/dist/components/index.cjs +137 -135
  81. package/dist/components/index.cjs.map +1 -1
  82. package/dist/components/index.js +13 -11
  83. package/dist/components/index.js.map +1 -1
  84. package/dist/components/navigation/index.cjs +3 -3
  85. package/dist/components/navigation/index.js +2 -2
  86. package/dist/components/onboarding-guides/index.cjs +7 -7
  87. package/dist/components/onboarding-guides/index.js +6 -6
  88. package/dist/components/related-content/index.cjs +5 -5
  89. package/dist/components/related-content/index.js +4 -4
  90. package/dist/components/tickets/index.cjs +6 -6
  91. package/dist/components/tickets/index.js +5 -5
  92. package/dist/components/ui/index.cjs +3 -3
  93. package/dist/components/ui/index.js +2 -2
  94. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  95. package/dist/hooks/index.cjs +4 -2
  96. package/dist/hooks/index.cjs.map +1 -1
  97. package/dist/hooks/index.js +3 -1
  98. package/dist/hooks/use-near-viewport.d.ts +7 -0
  99. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  100. package/dist/index.cjs +7 -3
  101. package/dist/index.cjs.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/package.json +1 -1
  104. package/src/components/features/cards-strip.tsx +2 -1
  105. package/src/components/features/highlight-video-section.tsx +10 -7
  106. package/src/components/features/media-gallery-manager.tsx +6 -8
  107. package/src/components/features/mux-origins.ts +40 -2
  108. package/src/components/features/policy-configuration-panel.tsx +34 -49
  109. package/src/components/features/release-media-manager.tsx +6 -8
  110. package/src/components/features/use-video-warmup.ts +19 -3
  111. package/src/components/features/video-bites-strip.tsx +24 -4
  112. package/src/components/features/video-source-selector.tsx +16 -56
  113. package/src/components/features/video.tsx +103 -8
  114. package/src/components/ui/rich-markdown-renderer.tsx +20 -0
  115. package/src/components/ui/simple-markdown-renderer.tsx +9 -5
  116. package/src/hooks/use-near-viewport.ts +9 -1
  117. package/src/stories/PolicyConfigurationPanel.stories.tsx +8 -6
  118. package/dist/chunk-3BHV2QAH.js.map +0 -1
  119. package/dist/chunk-QL2EI56X.cjs.map +0 -1
  120. package/dist/chunk-VIJARDBM.cjs.map +0 -1
  121. /package/dist/{chunk-H352AZJN.js.map → chunk-3GBLPPSW.js.map} +0 -0
  122. /package/dist/{chunk-TCEBFMLQ.js.map → chunk-4ONE3HQP.js.map} +0 -0
  123. /package/dist/{chunk-K4RASNQM.js.map → chunk-5BJJRPDH.js.map} +0 -0
  124. /package/dist/{chunk-H2DYCT6M.js.map → chunk-6TAIXSF5.js.map} +0 -0
  125. /package/dist/{chunk-7R256DKK.js.map → chunk-D2NLNBLH.js.map} +0 -0
  126. /package/dist/{chunk-Z4N7XLFG.js.map → chunk-G7QSLGMI.js.map} +0 -0
  127. /package/dist/{chunk-22NSWMI6.js.map → chunk-SHC5NFOH.js.map} +0 -0
  128. /package/dist/{chunk-3DQ5BFPM.js.map → chunk-TMZYFYSS.js.map} +0 -0
  129. /package/dist/{chunk-FIAXFNAA.js.map → chunk-WMOF3WQT.js.map} +0 -0
  130. /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
  131. /package/dist/{chunk-MQUS6BFR.js.map → chunk-ZQVEB6KT.js.map} +0 -0
  132. /package/dist/{chunk-XPZLIONZ.js.map → chunk-ZYB3BYBA.js.map} +0 -0
@@ -1,12 +1,17 @@
1
1
  /**
2
- * Mux CDN origin constants — single source of truth, server-safe.
2
+ * Mux CDN origins + playback-URL helpers — single source of truth, server-safe.
3
3
  *
4
4
  * Lives in its own NON-`'use client'` module so server-side hub
5
5
  * modules (webhook handlers, URL builders, hostname comparisons in
6
- * `lib/config/mux-config.ts`) can import these strings without
6
+ * `lib/config/mux-config.ts`) can import these without
7
7
  * tripping Next.js's client-reference poisoning. Re-exported from
8
8
  * `use-video-warmup.ts` for backward-compat with client-side callers.
9
9
  *
10
+ * The Mux-HLS detection in `toMuxPreviewUrl` intentionally overlaps the
11
+ * hub's `lib/config/mux-config.ts` `isMuxHlsUrl` — same documented
12
+ * cross-repo duplication umbrella as the origin constants (the hub's
13
+ * server code cannot depend on this package's client-marked siblings).
14
+ *
10
15
  * Bug history (2026-05-29): when these constants lived in
11
16
  * `use-video-warmup.ts` (which is `'use client'`), the hub's
12
17
  * server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed
@@ -25,3 +30,36 @@ export const MUX_STREAM_ORIGIN = 'https://stream.mux.com'
25
30
 
26
31
  /** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */
27
32
  export const MUX_IMAGE_ORIGIN = 'https://image.mux.com'
33
+
34
+ /** Valid values for Mux's `max_resolution` playback modifier (720p is the floor). */
35
+ export type MuxMaxResolution = '720p' | '1080p' | '1440p' | '2160p'
36
+
37
+ /**
38
+ * Cap the renditions offered by a public Mux HLS manifest for small
39
+ * preview players (e.g. the ~234px video-bite hover cards) by appending
40
+ * Mux's `max_resolution` playback modifier.
41
+ *
42
+ * Why a URL param and not a player prop: MuxPlayer's `maxResolution`
43
+ * prop only applies when playing via `playbackId` — our SSOT passes
44
+ * `src` URLs. And on Safari (native HLS, no hls.js) manifest-level
45
+ * filtering is the ONLY rendition cap; hls.js's `capLevelToPlayerSize`
46
+ * doesn't exist there.
47
+ *
48
+ * Pass-through (returns `url` unchanged) for: non-Mux hosts (Supabase
49
+ * MP4s, YouTube), non-`.m3u8` paths (MP4 renditions), signed URLs
50
+ * (`?token=` — Mux forbids loose modifiers alongside a token; they must
51
+ * live in the JWT), and unparseable strings.
52
+ */
53
+ export function toMuxPreviewUrl(url: string, maxResolution: MuxMaxResolution = '720p'): string {
54
+ let parsed: URL
55
+ try {
56
+ parsed = new URL(url)
57
+ } catch {
58
+ return url
59
+ }
60
+ if (parsed.origin !== MUX_STREAM_ORIGIN) return url
61
+ if (!parsed.pathname.endsWith('.m3u8')) return url
62
+ if (parsed.searchParams.has('token')) return url
63
+ parsed.searchParams.set('max_resolution', maxResolution)
64
+ return parsed.toString()
65
+ }
@@ -1,9 +1,10 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { useRef, useEffect, useState } from 'react'
4
+ import { useEffect, useState } from 'react'
5
5
  import { ChevronDown, Shield } from 'lucide-react'
6
6
  import { cn } from '../../utils/cn'
7
+ import { useCollapsible } from '../chat/hooks/use-collapsible'
7
8
  import { ToolIcon } from '../tool-icon'
8
9
  import { Button } from '../ui/button'
9
10
  import {
@@ -19,21 +20,22 @@ import {
19
20
  PolicyConfigurationPanelProps
20
21
  } from '../../types/permissions'
21
22
 
22
- const approvalLevelOptions: { value: ApprovalLevel; label: string }[] = [
23
- { value: 'ALLOW', label: 'Allow' },
24
- { value: 'ASK_USER', label: 'Ask User' },
25
- { value: 'ASK_TECHNICIAN', label: 'Ask Technician' },
26
- { value: 'DENY', label: 'Restrict' },
27
- ]
28
-
29
- const getApprovalLevelLabel = (level: ApprovalLevel | undefined, editMode: boolean = false): string => {
30
- if (!level) {
31
- return editMode ? 'Set Global Permission' : ''
32
- }
33
- const option = approvalLevelOptions.find(opt => opt.value === level)
34
- return option?.label || level
23
+ const approvalLevelLabels: Record<ApprovalLevel, string> = {
24
+ ALLOW: 'Allow',
25
+ ASK_USER: 'Ask User',
26
+ ASK_TECHNICIAN: 'Ask Technician',
27
+ DENY: 'Restrict',
35
28
  }
36
29
 
30
+ const approvalLevelOptions = (Object.keys(approvalLevelLabels) as ApprovalLevel[]).map(
31
+ value => ({ value, label: approvalLevelLabels[value] })
32
+ )
33
+
34
+ const POLICY_LEVEL_WIDTH = 'w-[180px]'
35
+ const CATEGORY_LEVEL_WIDTH = 'w-[256px]'
36
+
37
+ const getApprovalLevelLabel = (level: ApprovalLevel): string => approvalLevelLabels[level] ?? level
38
+
37
39
  const ApprovalLevelDropdown: React.FC<{
38
40
  value: ApprovalLevel | undefined;
39
41
  onChange: (level: ApprovalLevel | undefined) => void;
@@ -53,7 +55,7 @@ const ApprovalLevelDropdown: React.FC<{
53
55
  triggerClassName
54
56
  )}
55
57
  >
56
- {getApprovalLevelLabel(value, true)}
58
+ {value ? getApprovalLevelLabel(value) : 'Set Global Permission'}
57
59
  </Button>
58
60
  </DropdownMenuTrigger>
59
61
  <DropdownMenuContent align="end" className={contentClassName}>
@@ -106,7 +108,7 @@ const PolicyRow: React.FC<{
106
108
  value={policy.approvalLevel}
107
109
  onChange={handleChange}
108
110
  triggerClassName="w-full"
109
- contentClassName="w-[180px]"
111
+ contentClassName={POLICY_LEVEL_WIDTH}
110
112
  />
111
113
  </div>
112
114
  ) : policy.approvalLevel ? (
@@ -122,11 +124,11 @@ const PolicyRow: React.FC<{
122
124
  <ApprovalLevelDropdown
123
125
  value={policy.approvalLevel}
124
126
  onChange={handleChange}
125
- triggerClassName="w-[180px]"
126
- contentClassName="w-[180px]"
127
+ triggerClassName={POLICY_LEVEL_WIDTH}
128
+ contentClassName={POLICY_LEVEL_WIDTH}
127
129
  />
128
130
  ) : (
129
- <div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[180px]">
131
+ <div className={cn("px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)]", POLICY_LEVEL_WIDTH)}>
130
132
  {policy.approvalLevel && (
131
133
  <span className="text-h4 text-ods-text-primary">
132
134
  {getApprovalLevelLabel(policy.approvalLevel)}
@@ -139,20 +141,6 @@ const PolicyRow: React.FC<{
139
141
  )
140
142
  }
141
143
 
142
- const useAnimatedHeight = (isExpanded: boolean) => {
143
- const [height, setHeight] = useState<number | 'auto'>(0);
144
- const contentRef = useRef<HTMLDivElement>(null);
145
-
146
- useEffect(() => {
147
- if (contentRef.current) {
148
- const contentHeight = contentRef.current.scrollHeight;
149
- setHeight(isExpanded ? contentHeight : 0);
150
- }
151
- }, [isExpanded]);
152
-
153
- return { contentRef, height };
154
- };
155
-
156
144
  const CategorySection: React.FC<{
157
145
  category: PermissionCategory;
158
146
  editMode: boolean;
@@ -171,7 +159,7 @@ const CategorySection: React.FC<{
171
159
  onBulkLevelChange,
172
160
  onPolicyPermissionChange
173
161
  }) => {
174
- const { contentRef, height } = useAnimatedHeight(isExpanded);
162
+ const { innerRef, containerStyle } = useCollapsible({ expanded: isExpanded, durationMs: 300 })
175
163
  const handleBulkChange = (level: ApprovalLevel | undefined) => onBulkLevelChange(category.id, level)
176
164
 
177
165
  return (
@@ -203,7 +191,7 @@ const CategorySection: React.FC<{
203
191
  onChange={handleBulkChange}
204
192
  allowClear
205
193
  triggerClassName="w-full"
206
- contentClassName="w-[256px]"
194
+ contentClassName={CATEGORY_LEVEL_WIDTH}
207
195
  />
208
196
  </div>
209
197
  )}
@@ -216,15 +204,12 @@ const CategorySection: React.FC<{
216
204
  value={bulkLevel}
217
205
  onChange={handleBulkChange}
218
206
  allowClear
219
- triggerClassName="w-[256px]"
220
- contentClassName="w-[256px]"
207
+ triggerClassName={CATEGORY_LEVEL_WIDTH}
208
+ contentClassName={CATEGORY_LEVEL_WIDTH}
221
209
  />
222
210
  </div>
223
211
  ) : (
224
- <div
225
- className="hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[256px] shrink-0"
226
- onClick={(e) => e.stopPropagation()}
227
- />
212
+ <div className={cn("hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] shrink-0", CATEGORY_LEVEL_WIDTH)} />
228
213
  )}
229
214
 
230
215
  {/* Expand/Collapse Button */}
@@ -248,17 +233,16 @@ const CategorySection: React.FC<{
248
233
 
249
234
  {/* Policies List */}
250
235
  <div
251
- ref={contentRef}
252
236
  style={{
253
- height: height === 'auto' ? 'auto' : `${height}px`,
254
- transition: 'height 300ms ease-in-out, opacity 300ms ease-in-out'
237
+ ...containerStyle,
238
+ transition: `${containerStyle.transition}, opacity 300ms ease-in-out, visibility 300ms`,
239
+ // visibility drops collapsed content from the tab order and a11y
240
+ // tree; its discrete transition delays hiding until the fade ends.
241
+ visibility: isExpanded ? 'visible' : 'hidden',
255
242
  }}
256
- className={cn(
257
- "overflow-hidden",
258
- isExpanded ? "opacity-100" : "opacity-0"
259
- )}
243
+ className={isExpanded ? "opacity-100" : "opacity-0"}
260
244
  >
261
- <div className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
245
+ <div ref={innerRef} className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
262
246
  <div className="border border-ods-border rounded-md overflow-hidden">
263
247
  {category.policies.map((policy) => (
264
248
  <PolicyRow
@@ -297,6 +281,7 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
297
281
  const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set())
298
282
  const [bulkLevels, setBulkLevels] = useState<Record<string, ApprovalLevel | undefined>>({})
299
283
 
284
+ // biome-ignore lint/correctness/useExhaustiveDependencies: editMode is the reset trigger, not read in the body
300
285
  useEffect(() => {
301
286
  setBulkLevels({})
302
287
  }, [editMode])
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { useState, useRef } from 'react';
4
4
  import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from '../ui';
5
- import { Trash2, Plus, Image as ImageIcon, Video, Upload, Loader2, GripVertical } from 'lucide-react';
5
+ import { Trash2, Plus, Image as ImageIcon, Video as VideoIcon, Upload, Loader2, GripVertical } from 'lucide-react';
6
6
  import Image from '../../embed-shims/next-image';
7
+ import { Video } from './video';
7
8
 
8
9
  export interface ReleaseMediaItem {
9
10
  media_type: 'image' | 'video' | 'screenshot' | 'demo';
@@ -113,7 +114,7 @@ export function ReleaseMediaManager({
113
114
  switch (type) {
114
115
  case 'video':
115
116
  case 'demo':
116
- return <Video className="w-5 h-5 text-ods-text-secondary" />;
117
+ return <VideoIcon className="w-5 h-5 text-ods-text-secondary" />;
117
118
  default:
118
119
  return <ImageIcon className="w-5 h-5 text-ods-text-secondary" />;
119
120
  }
@@ -205,12 +206,9 @@ export function ReleaseMediaManager({
205
206
  {item.media_url && (
206
207
  <div className="aspect-video relative bg-ods-bg">
207
208
  {item.media_type === 'video' || item.media_type === 'demo' ? (
208
- <video
209
- src={item.media_url}
210
- className="w-full h-full object-cover"
211
- controls
212
- preload="metadata"
213
- />
209
+ // <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
210
+ // fit="cover" crops to the aspect-video cell.
211
+ <Video kind="file" url={item.media_url} fit="cover" className="w-full h-full" />
214
212
  ) : (
215
213
  <Image
216
214
  src={item.media_url}
@@ -48,6 +48,18 @@ import { useChatRuntime } from '../../contexts/chat-runtime-context'
48
48
  export { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
49
49
  import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
50
50
 
51
+ /**
52
+ * Save-Data detection — the ONE source of truth for "is this a metered
53
+ * connection". Consumed by the preload gate below and by `video.tsx`'s
54
+ * default `preload` policy. SSR-safe (returns false on the server).
55
+ */
56
+ export function saveDataEnabled(): boolean {
57
+ if (typeof navigator === 'undefined') return false
58
+ type Connection = { saveData?: boolean }
59
+ const conn = (navigator as Navigator & { connection?: Connection }).connection
60
+ return conn?.saveData === true
61
+ }
62
+
51
63
  /**
52
64
  * Preconnect-only variant — fires the three video-bearing origin
53
65
  * preconnects (Supabase Storage + Mux stream + Mux image) without
@@ -68,6 +80,12 @@ import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
68
80
  * any equivalent provider that wires the field) get the origin
69
81
  * automatically. The explicit `supabaseStorageOrigin` argument
70
82
  * overrides the runtime value when set.
83
+ *
84
+ * The hub's `app/layout.tsx` also emits STATIC `<link rel="preconnect">`
85
+ * tags for the same Mux origins (document-parse-time warmup; React
86
+ * dedupes against these runtime calls via the matching
87
+ * crossOrigin="anonymous"). A change to the origin list here must be
88
+ * mirrored there.
71
89
  */
72
90
  export function useVideoOriginPreconnect({
73
91
  supabaseStorageOrigin,
@@ -139,9 +157,7 @@ export function useVideoWarmup<T extends Element = HTMLDivElement>({
139
157
  if (!isNear || !videoUrl || !resolvedOrigin) return
140
158
 
141
159
  // Save-Data gate — metered connections skip preload.
142
- type Connection = { saveData?: boolean }
143
- const conn = (navigator as Navigator & { connection?: Connection }).connection
144
- if (conn?.saveData === true) return
160
+ if (saveDataEnabled()) return
145
161
 
146
162
  // Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms
147
163
  // via the manifest fetch when MuxPlayer mounts; YouTube has no
@@ -29,6 +29,8 @@ import React, { useMemo, useRef, useState, useEffect } from 'react';
29
29
  import { cn } from '../../utils/cn';
30
30
  import { Video } from './video';
31
31
  import { useVideoWarmup } from './use-video-warmup';
32
+ import { toMuxPreviewUrl } from './mux-origins';
33
+ import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
32
34
  import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
33
35
  import type { VideoTeaserWithRatio } from './video-ratio-tabs';
34
36
  import {
@@ -227,7 +229,16 @@ export function VideoBiteCard({
227
229
  const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
228
230
  const targetHref = bite.href ?? sectionHref;
229
231
  const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
232
+ // Strip keys and navigation stay on the RAW bite.url — only playback gets
233
+ // the rendition-capped preview URL below, so keys are stable regardless of
234
+ // the cap and original + clone share one HTTP cache entry per URL.
230
235
  const key = cardKey ?? `${bite.url}__${index}`;
236
+ // Rendition-capped playback URL for this ~234px preview card: public Mux
237
+ // HLS manifests get `?max_resolution=720p` (the only Safari-side cap —
238
+ // native HLS ignores hls.js's player-size capping); everything else passes
239
+ // through unchanged. Used by BOTH the first-frame facade and the hover
240
+ // player so the two layers share the manifest fetch.
241
+ const previewUrl = toMuxPreviewUrl(bite.url);
231
242
 
232
243
  // Hover activation: controlled by the strip (activeKey) or self-managed
233
244
  // when rendered standalone (admin editor).
@@ -250,7 +261,7 @@ export function VideoBiteCard({
250
261
  if (!el || typeof IntersectionObserver === 'undefined') return;
251
262
  const io = new IntersectionObserver(
252
263
  entries => setIsNear(entries[0]?.isIntersecting ?? false),
253
- { rootMargin: '500px' },
264
+ { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
254
265
  );
255
266
  io.observe(el);
256
267
  return () => io.disconnect();
@@ -392,7 +403,7 @@ export function VideoBiteCard({
392
403
  className="object-cover"
393
404
  />
394
405
  ) : (
395
- <Video kind="file" url={bite.url} firstFrameOnly layout="fill" />
406
+ <Video kind="file" url={previewUrl} firstFrameOnly layout="fill" />
396
407
  )}
397
408
 
398
409
  {/* Play affordance for every non-playing state (resting poster,
@@ -415,10 +426,19 @@ export function VideoBiteCard({
415
426
  it keeps playing. Sound at 50% (pre-activation: muted start +
416
427
  live unmute on the user's first gesture); CHROMELESS — the
417
428
  card's mux-replica badge above is the play affordance for
418
- every non-playing state. */}
429
+ every non-playing state.
430
+
431
+ INVARIANTS (do not regress):
432
+ - No explicit `preload` — the SSOT default ('metadata',
433
+ 'none' under Save-Data) buffers manifest + ~1 segment on
434
+ mount, which is exactly what makes hover start instant.
435
+ - The poster <Image> layer above stays mounted UNDER this
436
+ player permanently, and play() is imperative — never gated
437
+ on poster load — so there is no black flash and no
438
+ loading state between hover and first frame. */}
419
439
  <Video
420
440
  kind="file"
421
- url={bite.url}
441
+ url={previewUrl}
422
442
  poster={bite.thumbnail_url}
423
443
  playWhenHovered={isActive}
424
444
  chromeless
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useCallback, useEffect } from 'react';
4
- import { Upload, Sparkles, X, Video, Loader2 } from 'lucide-react';
3
+ import React, { useState, useCallback } from 'react';
4
+ import { Upload, X, Video as VideoIcon } from 'lucide-react';
5
5
  import { AIGeneratedBadge } from '../ui/ai-generated-badge';
6
6
  import { Button } from '../ui/button';
7
7
  import { Input } from '../ui/input';
8
8
  import { Label } from '../ui/label';
9
9
  import { Badge } from '../ui/badge';
10
10
  import { YouTubeIcon } from '../icons/youtube-icon';
11
+ import { Video } from './video';
11
12
 
12
13
  export type VideoSourceType = 'youtube' | 'uploaded';
13
14
 
@@ -48,16 +49,6 @@ export interface VideoSourceSelectorProps {
48
49
  onDelete: () => void;
49
50
  isAIGenerated?: boolean;
50
51
  }>;
51
- /**
52
- * Optional: resolve the uploaded video URL before it's handed to the DEFAULT
53
- * `<video>` preview (no-op when a `VideoPreviewComponent` is supplied — that
54
- * component owns its own rendering). Lets a consumer swap a URL that a native
55
- * `<video>` can't play — e.g. a Mux HLS manifest (`stream.mux.com/{id}.m3u8`),
56
- * which Chrome/Firefox/Edge can't play natively — for a playable MP4. Without
57
- * it the URL is used as-is (backward compatible). A rejected resolve degrades
58
- * gracefully to the original URL.
59
- */
60
- resolveVideoUrl?: (url: string) => Promise<string>;
61
52
  /** Optional: Upload progress bar component */
62
53
  UploadProgressComponent?: React.ComponentType<{
63
54
  progress: number;
@@ -95,7 +86,6 @@ export function VideoSourceSelector({
95
86
  uploadEmptyText = 'No video uploaded yet. Click "Upload Video" to add one.',
96
87
  disabled = false,
97
88
  VideoPreviewComponent,
98
- resolveVideoUrl,
99
89
  UploadProgressComponent,
100
90
  title = 'Video',
101
91
  showTitle = true,
@@ -149,7 +139,7 @@ export function VideoSourceSelector({
149
139
  {/* Section Title */}
150
140
  {showTitle && (
151
141
  <h3 className="text-h5 text-ods-text-primary flex items-center gap-2">
152
- <Video className="h-5 w-5" />
142
+ <VideoIcon className="h-5 w-5" />
153
143
  {title}
154
144
  </h3>
155
145
  )}
@@ -256,10 +246,8 @@ export function VideoSourceSelector({
256
246
  isAIGenerated={isAIGenerated}
257
247
  />
258
248
  ) : (
259
- // Default simple preview (resolver-aware — see DefaultVideoPreview)
260
249
  <DefaultVideoPreview
261
250
  videoUrl={mainVideoUrl}
262
- resolveVideoUrl={resolveVideoUrl}
263
251
  onDelete={handleDeleteVideo}
264
252
  disabled={disabled}
265
253
  />
@@ -276,55 +264,27 @@ export function VideoSourceSelector({
276
264
  }
277
265
 
278
266
  interface DefaultVideoPreviewProps {
279
- /** Uploaded video URL (possibly an HLS manifest a native `<video>` can't play). */
267
+ /** Uploaded video URL — MP4 or Mux HLS manifest, both playable via <Video>. */
280
268
  videoUrl: string;
281
- /** Optional resolver → playable MP4 URL; omit for as-is (backward-compatible) behaviour. */
282
- resolveVideoUrl?: (url: string) => Promise<string>;
283
269
  onDelete: () => void;
284
270
  disabled?: boolean;
285
271
  }
286
272
 
287
273
  /**
288
- * Default `<video>` preview for {@link VideoSourceSelector} when no
289
- * `VideoPreviewComponent` is supplied. When `resolveVideoUrl` is provided the URL
290
- * is resolved (e.g. Mux HLS → MP4) before it reaches the native `<video>`, which
291
- * can't play HLS on Chrome/Firefox/Edge; a rejected resolve degrades to the
292
- * original URL. Without a resolver the URL is used synchronously, unchanged.
274
+ * Default preview for {@link VideoSourceSelector} when no
275
+ * `VideoPreviewComponent` is supplied. Renders through the `<Video>` SSOT
276
+ * (MuxPlayer), which plays Mux HLS manifests directly on every browser —
277
+ * no HLS→MP4 resolve step needed for playback.
293
278
  */
294
- function DefaultVideoPreview({ videoUrl, resolveVideoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
295
- // `undefined` only while an async resolve is in flight (first paint); otherwise
296
- // the (possibly resolved) URL. Synchronous passthrough when there's no resolver.
297
- const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
298
- resolveVideoUrl ? undefined : videoUrl,
299
- );
300
-
301
- useEffect(() => {
302
- if (!resolveVideoUrl) {
303
- setResolvedUrl(videoUrl);
304
- return;
305
- }
306
- // Guard against a late resolve overwriting a newer URL / unmounted preview.
307
- let active = true;
308
- setResolvedUrl(undefined);
309
- resolveVideoUrl(videoUrl)
310
- .then((url) => { if (active) setResolvedUrl(url); })
311
- .catch(() => { if (active) setResolvedUrl(videoUrl); });
312
- return () => { active = false; };
313
- }, [videoUrl, resolveVideoUrl]);
314
-
279
+ function DefaultVideoPreview({ videoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
315
280
  return (
316
281
  <div className="relative rounded-lg border border-ods-border overflow-hidden">
317
- {resolvedUrl === undefined ? (
318
- <div className="w-full h-[200px] flex items-center justify-center bg-black">
319
- <Loader2 className="h-6 w-6 animate-spin text-ods-accent" />
320
- </div>
321
- ) : (
322
- <video
323
- src={resolvedUrl}
324
- className="w-full h-auto max-h-[300px] object-contain bg-black"
325
- controls
326
- />
327
- )}
282
+ {/* aspect-video box: MuxPlayer fills its container (width/height 100%),
283
+ so the wrapper must reserve the height the intrinsic-sized native
284
+ <video> used to provide. */}
285
+ <div className="w-full aspect-video max-h-[300px]">
286
+ <Video kind="file" url={videoUrl} />
287
+ </div>
328
288
  <button
329
289
  type="button"
330
290
  onClick={onDelete}