@flamingo-stack/openframe-frontend-core 0.0.446-snapshot.20260717214810 → 0.0.446
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-X4XHW3OV.cjs → chunk-3AFV2YIC.cjs} +40 -40
- package/dist/{chunk-X4XHW3OV.cjs.map → chunk-3AFV2YIC.cjs.map} +1 -1
- package/dist/{chunk-TKGCTDQY.js → chunk-5NYXGWBN.js} +2 -4
- package/dist/{chunk-TKGCTDQY.js.map → chunk-5NYXGWBN.js.map} +1 -1
- package/dist/{chunk-S7VMUDUB.cjs → chunk-5WX2V6CY.cjs} +12 -12
- package/dist/{chunk-S7VMUDUB.cjs.map → chunk-5WX2V6CY.cjs.map} +1 -1
- package/dist/{chunk-3GBLPPSW.js → chunk-5YQSRQHP.js} +6 -6
- package/dist/{chunk-FR6NVZVM.cjs → chunk-6XZNIPXP.cjs} +18 -18
- package/dist/{chunk-FR6NVZVM.cjs.map → chunk-6XZNIPXP.cjs.map} +1 -1
- package/dist/{chunk-2PNWAAU4.cjs → chunk-7IC2QCOL.cjs} +73 -73
- package/dist/{chunk-2PNWAAU4.cjs.map → chunk-7IC2QCOL.cjs.map} +1 -1
- package/dist/{chunk-RNXMP7GB.cjs → chunk-BAUWT7PV.cjs} +941 -986
- package/dist/chunk-BAUWT7PV.cjs.map +1 -0
- package/dist/{chunk-ZYB3BYBA.js → chunk-BNYI2FVM.js} +4 -4
- package/dist/{chunk-EZMM2OMP.cjs → chunk-DH3DUY6Q.cjs} +32 -32
- package/dist/{chunk-EZMM2OMP.cjs.map → chunk-DH3DUY6Q.cjs.map} +1 -1
- package/dist/{chunk-YHNBJ3F3.js → chunk-G2KXNMFQ.js} +2 -2
- package/dist/{chunk-TMZYFYSS.js → chunk-GTHA2SYB.js} +5 -5
- package/dist/{chunk-D2NLNBLH.js → chunk-I7IOEXRJ.js} +2 -2
- package/dist/{chunk-6TAIXSF5.js → chunk-K6JIBV3X.js} +2 -2
- package/dist/{chunk-ITJMUH4Q.cjs → chunk-L276GOMD.cjs} +40 -40
- package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-L276GOMD.cjs.map} +1 -1
- package/dist/{chunk-YCSNDUDE.js → chunk-MAB4TQCO.js} +297 -342
- package/dist/chunk-MAB4TQCO.js.map +1 -0
- package/dist/{chunk-ZQVEB6KT.js → chunk-MR7BMJHI.js} +6 -6
- package/dist/{chunk-PF6CKZIH.cjs → chunk-MS5CEBBQ.cjs} +5 -5
- package/dist/{chunk-PF6CKZIH.cjs.map → chunk-MS5CEBBQ.cjs.map} +1 -1
- package/dist/{chunk-XUS75SQW.cjs → chunk-N3Y6JYH5.cjs} +15 -15
- package/dist/{chunk-XUS75SQW.cjs.map → chunk-N3Y6JYH5.cjs.map} +1 -1
- package/dist/{chunk-5BJJRPDH.js → chunk-N4CCB33C.js} +3 -3
- package/dist/{chunk-WLU37UZO.cjs → chunk-NQLY27GH.cjs} +73 -73
- package/dist/{chunk-WLU37UZO.cjs.map → chunk-NQLY27GH.cjs.map} +1 -1
- package/dist/{chunk-ROCJMSJI.cjs → chunk-PTT4J5GF.cjs} +3 -3
- package/dist/{chunk-ROCJMSJI.cjs.map → chunk-PTT4J5GF.cjs.map} +1 -1
- package/dist/{chunk-E6HKLQ3Q.cjs → chunk-SA7AHDS6.cjs} +7 -7
- package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-SA7AHDS6.cjs.map} +1 -1
- package/dist/{chunk-4ONE3HQP.js → chunk-SAV4U37G.js} +2 -2
- package/dist/{chunk-WMOF3WQT.js → chunk-SCKAD2MY.js} +5 -5
- package/dist/{chunk-AN7MCJTV.cjs → chunk-VIJARDBM.cjs} +3 -5
- package/dist/chunk-VIJARDBM.cjs.map +1 -0
- package/dist/{chunk-G7QSLGMI.js → chunk-XB6D7B3I.js} +3 -3
- package/dist/{chunk-SHC5NFOH.js → chunk-YLMD4UFA.js} +3 -3
- package/dist/{chunk-KYLOPUIH.cjs → chunk-ZKYEMGMD.cjs} +29 -29
- package/dist/{chunk-KYLOPUIH.cjs.map → chunk-ZKYEMGMD.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/highlight-video-section.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -5
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -4
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/mux-origins.cjs +0 -14
- package/dist/components/features/mux-origins.cjs.map +1 -1
- package/dist/components/features/mux-origins.d.ts +2 -26
- package/dist/components/features/mux-origins.d.ts.map +1 -1
- package/dist/components/features/mux-origins.js +1 -14
- package/dist/components/features/mux-origins.js.map +1 -1
- package/dist/components/features/release-media-manager.d.ts.map +1 -1
- package/dist/components/features/use-video-warmup.d.ts +0 -12
- package/dist/components/features/use-video-warmup.d.ts.map +1 -1
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-source-selector.d.ts +11 -1
- package/dist/components/features/video-source-selector.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +1 -14
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +135 -137
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +11 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/hooks/index.cjs +2 -4
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +1 -3
- package/dist/hooks/use-near-viewport.d.ts +0 -7
- package/dist/hooks/use-near-viewport.d.ts.map +1 -1
- package/dist/index.cjs +3 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2 -6
- package/package.json +1 -1
- package/src/components/features/cards-strip.tsx +1 -2
- package/src/components/features/highlight-video-section.tsx +7 -10
- package/src/components/features/media-gallery-manager.tsx +8 -6
- package/src/components/features/mux-origins.ts +2 -40
- package/src/components/features/release-media-manager.tsx +8 -6
- package/src/components/features/use-video-warmup.ts +3 -19
- package/src/components/features/video-bites-strip.tsx +4 -24
- package/src/components/features/video-source-selector.tsx +56 -16
- package/src/components/features/video.tsx +8 -103
- package/src/components/ui/rich-markdown-renderer.tsx +0 -20
- package/src/components/ui/simple-markdown-renderer.tsx +5 -9
- package/src/hooks/use-near-viewport.ts +1 -9
- package/dist/chunk-AN7MCJTV.cjs.map +0 -1
- package/dist/chunk-RNXMP7GB.cjs.map +0 -1
- package/dist/chunk-YCSNDUDE.js.map +0 -1
- /package/dist/{chunk-3GBLPPSW.js.map → chunk-5YQSRQHP.js.map} +0 -0
- /package/dist/{chunk-ZYB3BYBA.js.map → chunk-BNYI2FVM.js.map} +0 -0
- /package/dist/{chunk-YHNBJ3F3.js.map → chunk-G2KXNMFQ.js.map} +0 -0
- /package/dist/{chunk-TMZYFYSS.js.map → chunk-GTHA2SYB.js.map} +0 -0
- /package/dist/{chunk-D2NLNBLH.js.map → chunk-I7IOEXRJ.js.map} +0 -0
- /package/dist/{chunk-6TAIXSF5.js.map → chunk-K6JIBV3X.js.map} +0 -0
- /package/dist/{chunk-ZQVEB6KT.js.map → chunk-MR7BMJHI.js.map} +0 -0
- /package/dist/{chunk-5BJJRPDH.js.map → chunk-N4CCB33C.js.map} +0 -0
- /package/dist/{chunk-4ONE3HQP.js.map → chunk-SAV4U37G.js.map} +0 -0
- /package/dist/{chunk-WMOF3WQT.js.map → chunk-SCKAD2MY.js.map} +0 -0
- /package/dist/{chunk-G7QSLGMI.js.map → chunk-XB6D7B3I.js.map} +0 -0
- /package/dist/{chunk-SHC5NFOH.js.map → chunk-YLMD4UFA.js.map} +0 -0
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mux CDN
|
|
2
|
+
* Mux CDN origin constants — 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 without
|
|
6
|
+
* `lib/config/mux-config.ts`) can import these strings 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
|
-
*
|
|
15
10
|
* Bug history (2026-05-29): when these constants lived in
|
|
16
11
|
* `use-video-warmup.ts` (which is `'use client'`), the hub's
|
|
17
12
|
* server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed
|
|
@@ -30,36 +25,3 @@ export const MUX_STREAM_ORIGIN = 'https://stream.mux.com'
|
|
|
30
25
|
|
|
31
26
|
/** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */
|
|
32
27
|
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
|
-
}
|
|
@@ -2,9 +2,8 @@
|
|
|
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
|
|
5
|
+
import { Trash2, Plus, Image as ImageIcon, Video, Upload, Loader2, GripVertical } from 'lucide-react';
|
|
6
6
|
import Image from '../../embed-shims/next-image';
|
|
7
|
-
import { Video } from './video';
|
|
8
7
|
|
|
9
8
|
export interface ReleaseMediaItem {
|
|
10
9
|
media_type: 'image' | 'video' | 'screenshot' | 'demo';
|
|
@@ -114,7 +113,7 @@ export function ReleaseMediaManager({
|
|
|
114
113
|
switch (type) {
|
|
115
114
|
case 'video':
|
|
116
115
|
case 'demo':
|
|
117
|
-
return <
|
|
116
|
+
return <Video className="w-5 h-5 text-ods-text-secondary" />;
|
|
118
117
|
default:
|
|
119
118
|
return <ImageIcon className="w-5 h-5 text-ods-text-secondary" />;
|
|
120
119
|
}
|
|
@@ -206,9 +205,12 @@ export function ReleaseMediaManager({
|
|
|
206
205
|
{item.media_url && (
|
|
207
206
|
<div className="aspect-video relative bg-ods-bg">
|
|
208
207
|
{item.media_type === 'video' || item.media_type === 'demo' ? (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
<video
|
|
209
|
+
src={item.media_url}
|
|
210
|
+
className="w-full h-full object-cover"
|
|
211
|
+
controls
|
|
212
|
+
preload="metadata"
|
|
213
|
+
/>
|
|
212
214
|
) : (
|
|
213
215
|
<Image
|
|
214
216
|
src={item.media_url}
|
|
@@ -48,18 +48,6 @@ 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
|
-
|
|
63
51
|
/**
|
|
64
52
|
* Preconnect-only variant — fires the three video-bearing origin
|
|
65
53
|
* preconnects (Supabase Storage + Mux stream + Mux image) without
|
|
@@ -80,12 +68,6 @@ export function saveDataEnabled(): boolean {
|
|
|
80
68
|
* any equivalent provider that wires the field) get the origin
|
|
81
69
|
* automatically. The explicit `supabaseStorageOrigin` argument
|
|
82
70
|
* 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.
|
|
89
71
|
*/
|
|
90
72
|
export function useVideoOriginPreconnect({
|
|
91
73
|
supabaseStorageOrigin,
|
|
@@ -157,7 +139,9 @@ export function useVideoWarmup<T extends Element = HTMLDivElement>({
|
|
|
157
139
|
if (!isNear || !videoUrl || !resolvedOrigin) return
|
|
158
140
|
|
|
159
141
|
// Save-Data gate — metered connections skip preload.
|
|
160
|
-
|
|
142
|
+
type Connection = { saveData?: boolean }
|
|
143
|
+
const conn = (navigator as Navigator & { connection?: Connection }).connection
|
|
144
|
+
if (conn?.saveData === true) return
|
|
161
145
|
|
|
162
146
|
// Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms
|
|
163
147
|
// via the manifest fetch when MuxPlayer mounts; YouTube has no
|
|
@@ -29,8 +29,6 @@ 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';
|
|
34
32
|
import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
|
|
35
33
|
import type { VideoTeaserWithRatio } from './video-ratio-tabs';
|
|
36
34
|
import {
|
|
@@ -229,16 +227,7 @@ export function VideoBiteCard({
|
|
|
229
227
|
const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
|
|
230
228
|
const targetHref = bite.href ?? sectionHref;
|
|
231
229
|
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.
|
|
235
230
|
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);
|
|
242
231
|
|
|
243
232
|
// Hover activation: controlled by the strip (activeKey) or self-managed
|
|
244
233
|
// when rendered standalone (admin editor).
|
|
@@ -261,7 +250,7 @@ export function VideoBiteCard({
|
|
|
261
250
|
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
262
251
|
const io = new IntersectionObserver(
|
|
263
252
|
entries => setIsNear(entries[0]?.isIntersecting ?? false),
|
|
264
|
-
{ rootMargin:
|
|
253
|
+
{ rootMargin: '500px' },
|
|
265
254
|
);
|
|
266
255
|
io.observe(el);
|
|
267
256
|
return () => io.disconnect();
|
|
@@ -403,7 +392,7 @@ export function VideoBiteCard({
|
|
|
403
392
|
className="object-cover"
|
|
404
393
|
/>
|
|
405
394
|
) : (
|
|
406
|
-
<Video kind="file" url={
|
|
395
|
+
<Video kind="file" url={bite.url} firstFrameOnly layout="fill" />
|
|
407
396
|
)}
|
|
408
397
|
|
|
409
398
|
{/* Play affordance for every non-playing state (resting poster,
|
|
@@ -426,19 +415,10 @@ export function VideoBiteCard({
|
|
|
426
415
|
it keeps playing. Sound at 50% (pre-activation: muted start +
|
|
427
416
|
live unmute on the user's first gesture); CHROMELESS — the
|
|
428
417
|
card's mux-replica badge above is the play affordance for
|
|
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. */}
|
|
418
|
+
every non-playing state. */}
|
|
439
419
|
<Video
|
|
440
420
|
kind="file"
|
|
441
|
-
url={
|
|
421
|
+
url={bite.url}
|
|
442
422
|
poster={bite.thumbnail_url}
|
|
443
423
|
playWhenHovered={isActive}
|
|
444
424
|
chromeless
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useCallback } from 'react';
|
|
4
|
-
import { Upload, X, Video
|
|
3
|
+
import React, { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Upload, Sparkles, X, Video, Loader2 } 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';
|
|
12
11
|
|
|
13
12
|
export type VideoSourceType = 'youtube' | 'uploaded';
|
|
14
13
|
|
|
@@ -49,6 +48,16 @@ export interface VideoSourceSelectorProps {
|
|
|
49
48
|
onDelete: () => void;
|
|
50
49
|
isAIGenerated?: boolean;
|
|
51
50
|
}>;
|
|
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>;
|
|
52
61
|
/** Optional: Upload progress bar component */
|
|
53
62
|
UploadProgressComponent?: React.ComponentType<{
|
|
54
63
|
progress: number;
|
|
@@ -86,6 +95,7 @@ export function VideoSourceSelector({
|
|
|
86
95
|
uploadEmptyText = 'No video uploaded yet. Click "Upload Video" to add one.',
|
|
87
96
|
disabled = false,
|
|
88
97
|
VideoPreviewComponent,
|
|
98
|
+
resolveVideoUrl,
|
|
89
99
|
UploadProgressComponent,
|
|
90
100
|
title = 'Video',
|
|
91
101
|
showTitle = true,
|
|
@@ -139,7 +149,7 @@ export function VideoSourceSelector({
|
|
|
139
149
|
{/* Section Title */}
|
|
140
150
|
{showTitle && (
|
|
141
151
|
<h3 className="text-h5 text-ods-text-primary flex items-center gap-2">
|
|
142
|
-
<
|
|
152
|
+
<Video className="h-5 w-5" />
|
|
143
153
|
{title}
|
|
144
154
|
</h3>
|
|
145
155
|
)}
|
|
@@ -246,8 +256,10 @@ export function VideoSourceSelector({
|
|
|
246
256
|
isAIGenerated={isAIGenerated}
|
|
247
257
|
/>
|
|
248
258
|
) : (
|
|
259
|
+
// Default simple preview (resolver-aware — see DefaultVideoPreview)
|
|
249
260
|
<DefaultVideoPreview
|
|
250
261
|
videoUrl={mainVideoUrl}
|
|
262
|
+
resolveVideoUrl={resolveVideoUrl}
|
|
251
263
|
onDelete={handleDeleteVideo}
|
|
252
264
|
disabled={disabled}
|
|
253
265
|
/>
|
|
@@ -264,27 +276,55 @@ export function VideoSourceSelector({
|
|
|
264
276
|
}
|
|
265
277
|
|
|
266
278
|
interface DefaultVideoPreviewProps {
|
|
267
|
-
/** Uploaded video URL
|
|
279
|
+
/** Uploaded video URL (possibly an HLS manifest a native `<video>` can't play). */
|
|
268
280
|
videoUrl: string;
|
|
281
|
+
/** Optional resolver → playable MP4 URL; omit for as-is (backward-compatible) behaviour. */
|
|
282
|
+
resolveVideoUrl?: (url: string) => Promise<string>;
|
|
269
283
|
onDelete: () => void;
|
|
270
284
|
disabled?: boolean;
|
|
271
285
|
}
|
|
272
286
|
|
|
273
287
|
/**
|
|
274
|
-
* Default preview for {@link VideoSourceSelector} when no
|
|
275
|
-
* `VideoPreviewComponent` is supplied.
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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.
|
|
278
293
|
*/
|
|
279
|
-
function DefaultVideoPreview({ videoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
|
|
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
|
+
|
|
280
315
|
return (
|
|
281
316
|
<div className="relative rounded-lg border border-ods-border overflow-hidden">
|
|
282
|
-
{
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
)}
|
|
288
328
|
<button
|
|
289
329
|
type="button"
|
|
290
330
|
onClick={onDelete}
|
|
@@ -34,21 +34,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
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 { saveDataEnabled } from './use-video-warmup';
|
|
38
|
-
|
|
39
|
-
// =============================================================================
|
|
40
|
-
// Dev-only hover→playing latency instrumentation gate. Always on in dev
|
|
41
|
-
// builds; opt-in in production via `localStorage.VIDEO_PERF_DEBUG` so an
|
|
42
|
-
// "instant hover" regression can be measured on a live deployment.
|
|
43
|
-
// =============================================================================
|
|
44
|
-
function videoPerfDebugEnabled(): boolean {
|
|
45
|
-
if (process.env.NODE_ENV !== 'production') return true;
|
|
46
|
-
try {
|
|
47
|
-
return typeof localStorage !== 'undefined' && localStorage.getItem('VIDEO_PERF_DEBUG') !== null;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
37
|
|
|
53
38
|
// =============================================================================
|
|
54
39
|
// Suppress Google Cast SDK loading (CSP-friendly)
|
|
@@ -268,19 +253,8 @@ interface VideoFileProps extends VideoCommonProps {
|
|
|
268
253
|
* seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
|
|
269
254
|
* fragmentless metadata load stays blank). No chrome, no playback, no
|
|
270
255
|
* MuxPlayer cost — the resting facade layer under strip cards when no
|
|
271
|
-
* poster asset exists.
|
|
272
|
-
* player props are ignored. */
|
|
256
|
+
* poster asset exists. All player props are ignored. */
|
|
273
257
|
firstFrameOnly?: boolean;
|
|
274
|
-
/** Media preload hint. When omitted, the SSOT default applies:
|
|
275
|
-
* `'metadata'` (manifest + ~1 segment buffered on mount — instant
|
|
276
|
-
* hover/click start, bounded by playback-core's maxBufferLength=1 clamp),
|
|
277
|
-
* downgraded to `'none'` on Save-Data connections. Pass a value only to
|
|
278
|
-
* override the policy deliberately. */
|
|
279
|
-
preload?: 'none' | 'metadata' | 'auto';
|
|
280
|
-
/** Object-fit for the media inside the player box. Default `'contain'`
|
|
281
|
-
* (MuxPlayer default); `'cover'` crops to fill — aspect-cropped grid
|
|
282
|
-
* cells and social-post mockups. */
|
|
283
|
-
fit?: 'contain' | 'cover';
|
|
284
258
|
}
|
|
285
259
|
|
|
286
260
|
interface VideoYouTubeProps extends VideoCommonProps {
|
|
@@ -301,8 +275,6 @@ interface VideoAutoProps extends VideoCommonProps {
|
|
|
301
275
|
playOnHover?: boolean;
|
|
302
276
|
playWhenHovered?: boolean;
|
|
303
277
|
firstFrameOnly?: boolean;
|
|
304
|
-
preload?: 'none' | 'metadata' | 'auto';
|
|
305
|
-
fit?: 'contain' | 'cover';
|
|
306
278
|
}
|
|
307
279
|
|
|
308
280
|
export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
@@ -328,12 +300,7 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
328
300
|
minimalControls={props.minimalControls}
|
|
329
301
|
/>
|
|
330
302
|
) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
|
|
331
|
-
<FirstFramePreview
|
|
332
|
-
url={url}
|
|
333
|
-
poster={props.poster}
|
|
334
|
-
fit={'fit' in props ? props.fit : undefined}
|
|
335
|
-
className={props.className}
|
|
336
|
-
/>
|
|
303
|
+
<FirstFramePreview url={url} className={props.className} />
|
|
337
304
|
) : (
|
|
338
305
|
<FilePlayer
|
|
339
306
|
url={url}
|
|
@@ -346,8 +313,6 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
346
313
|
chromeless={'chromeless' in props ? props.chromeless : undefined}
|
|
347
314
|
playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
|
|
348
315
|
playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
|
|
349
|
-
preload={'preload' in props ? props.preload : undefined}
|
|
350
|
-
fit={'fit' in props ? props.fit : undefined}
|
|
351
316
|
className={props.className}
|
|
352
317
|
/>
|
|
353
318
|
);
|
|
@@ -422,30 +387,16 @@ function wrapWithLayout(
|
|
|
422
387
|
* first frame (also fixes iOS Safari, which stays blank on a fragmentless
|
|
423
388
|
* metadata load). The transparent media background keeps the box showing the
|
|
424
389
|
* card surface (never black) until the frame decodes. */
|
|
425
|
-
function FirstFramePreview({
|
|
426
|
-
url,
|
|
427
|
-
poster,
|
|
428
|
-
fit,
|
|
429
|
-
className,
|
|
430
|
-
}: {
|
|
431
|
-
url: string;
|
|
432
|
-
poster?: string | null;
|
|
433
|
-
fit?: 'contain' | 'cover';
|
|
434
|
-
className?: string;
|
|
435
|
-
}): React.ReactElement {
|
|
390
|
+
function FirstFramePreview({ url, className }: { url: string; className?: string }): React.ReactElement {
|
|
436
391
|
return (
|
|
437
392
|
<div
|
|
438
393
|
aria-hidden
|
|
439
394
|
className="h-full w-full"
|
|
440
395
|
style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
|
|
441
396
|
>
|
|
442
|
-
{/* No explicit `preload` — inherits the SSOT default ('metadata',
|
|
443
|
-
'none' under Save-Data) so posterless facade cards honor the
|
|
444
|
-
Save-Data policy like every other surface. */}
|
|
445
397
|
<FilePlayer
|
|
446
398
|
url={`${url}#t=0.1`}
|
|
447
|
-
|
|
448
|
-
fit={fit}
|
|
399
|
+
preload="metadata"
|
|
449
400
|
muted
|
|
450
401
|
chromeless
|
|
451
402
|
className={className}
|
|
@@ -469,11 +420,8 @@ interface FilePlayerProps {
|
|
|
469
420
|
chromeless?: boolean;
|
|
470
421
|
playOnHover?: boolean;
|
|
471
422
|
playWhenHovered?: boolean;
|
|
472
|
-
/** Media preload hint —
|
|
473
|
-
* When omitted, defaults to 'metadata' ('none' under Save-Data). */
|
|
423
|
+
/** Media preload hint — the firstFrameOnly facade passes 'metadata'. */
|
|
474
424
|
preload?: 'none' | 'metadata' | 'auto';
|
|
475
|
-
/** Object-fit — 'cover' maps to media-chrome's `--media-object-fit`. */
|
|
476
|
-
fit?: 'contain' | 'cover';
|
|
477
425
|
className?: string;
|
|
478
426
|
}
|
|
479
427
|
|
|
@@ -489,21 +437,8 @@ function FilePlayer({
|
|
|
489
437
|
playOnHover,
|
|
490
438
|
playWhenHovered,
|
|
491
439
|
preload,
|
|
492
|
-
fit,
|
|
493
440
|
className,
|
|
494
441
|
}: FilePlayerProps): React.ReactElement {
|
|
495
|
-
// Explicit preload policy — never rely on the browser/MuxPlayer implicit
|
|
496
|
-
// default. 'metadata' makes playback-core load the manifest immediately
|
|
497
|
-
// and clamp buffering to maxBufferLength=1/maxBufferSize=1 (manifest +
|
|
498
|
-
// ~1 segment, then idle); play() restores full buffering automatically.
|
|
499
|
-
// That bounded prefetch is what makes hover/click start instant. Save-Data
|
|
500
|
-
// connections get 'none' (nothing until play()). SSR note: the server pass
|
|
501
|
-
// always emits 'metadata' (saveDataEnabled() is false server-side); for
|
|
502
|
-
// Save-Data users React reconciles the property to 'none' during hydration.
|
|
503
|
-
// The mux-player custom element upgrades from client JS, so the window for
|
|
504
|
-
// an early manifest fetch is at most one bounded manifest+segment — the
|
|
505
|
-
// Save-Data verification gate covers it (see plan A2.4).
|
|
506
|
-
const effectivePreload = preload ?? (saveDataEnabled() ? 'none' : 'metadata');
|
|
507
442
|
// True while hover playback is running MUTED because the browser's autoplay
|
|
508
443
|
// policy blocked sound (no user activation yet). Drives the center unmute
|
|
509
444
|
// control — the industry pattern (muted autoplay + explicit unmute button)
|
|
@@ -519,22 +454,7 @@ function FilePlayer({
|
|
|
519
454
|
pause?: () => void;
|
|
520
455
|
muted?: boolean;
|
|
521
456
|
volume?: number;
|
|
522
|
-
addEventListener?: (type: string, listener: () => void) => void;
|
|
523
|
-
removeEventListener?: (type: string, listener: () => void) => void;
|
|
524
457
|
} | null>(null);
|
|
525
|
-
// Dev/opt-in hover→'playing' latency metric (see videoPerfDebugEnabled).
|
|
526
|
-
// One listener at a time — re-entering hover replaces it; hover-leave and
|
|
527
|
-
// unmount clear it so no stale listener survives across generations.
|
|
528
|
-
const perfListenerRef = useRef<(() => void) | null>(null);
|
|
529
|
-
const clearPerfListener = useCallback(() => {
|
|
530
|
-
if (perfListenerRef.current) {
|
|
531
|
-
try {
|
|
532
|
-
hoverPlayerRef.current?.removeEventListener?.('playing', perfListenerRef.current);
|
|
533
|
-
} catch { /* element already torn down */ }
|
|
534
|
-
perfListenerRef.current = null;
|
|
535
|
-
}
|
|
536
|
-
}, []);
|
|
537
|
-
useEffect(() => clearPerfListener, [clearPerfListener]);
|
|
538
458
|
// Tracks whether the pointer is STILL over the player. A fast hover-out
|
|
539
459
|
// pauses the in-flight play(), which rejects it with AbortError — that must
|
|
540
460
|
// NOT trigger the muted retry (it would restart playback after the pointer
|
|
@@ -561,17 +481,6 @@ function FilePlayer({
|
|
|
561
481
|
const generation = ++hoverGenerationRef.current;
|
|
562
482
|
const el = hoverPlayerRef.current;
|
|
563
483
|
if (!el) return;
|
|
564
|
-
if (videoPerfDebugEnabled()) {
|
|
565
|
-
clearPerfListener();
|
|
566
|
-
const startedAt = performance.now();
|
|
567
|
-
const onPlaying = () => {
|
|
568
|
-
clearPerfListener();
|
|
569
|
-
// eslint-disable-next-line no-console
|
|
570
|
-
console.debug('[Video] hover→playing %dms', Math.round(performance.now() - startedAt));
|
|
571
|
-
};
|
|
572
|
-
perfListenerRef.current = onPlaying;
|
|
573
|
-
try { el.addEventListener?.('playing', onPlaying); } catch { /* ignore */ }
|
|
574
|
-
}
|
|
575
484
|
try {
|
|
576
485
|
el.volume = 0.5;
|
|
577
486
|
if (userHasInteracted) {
|
|
@@ -614,14 +523,13 @@ function FilePlayer({
|
|
|
614
523
|
activationWaiters.add(waiter);
|
|
615
524
|
}
|
|
616
525
|
} catch { /* ignore */ }
|
|
617
|
-
}, [
|
|
526
|
+
}, []);
|
|
618
527
|
const stopHoverPlayback = useCallback(() => {
|
|
619
528
|
hoverActiveRef.current = false;
|
|
620
529
|
setHoverMutedFallback(false);
|
|
621
530
|
clearActivationWaiter();
|
|
622
|
-
clearPerfListener();
|
|
623
531
|
try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
|
|
624
|
-
}, [clearActivationWaiter
|
|
532
|
+
}, [clearActivationWaiter]);
|
|
625
533
|
|
|
626
534
|
// Explicit unmute affordance: the click IS the user activation the autoplay
|
|
627
535
|
// policy wants, so unmuting here always succeeds (and the window-level
|
|
@@ -671,7 +579,7 @@ function FilePlayer({
|
|
|
671
579
|
src={url}
|
|
672
580
|
poster={poster || undefined}
|
|
673
581
|
streamType="on-demand"
|
|
674
|
-
preload={
|
|
582
|
+
preload={preload}
|
|
675
583
|
playsInline
|
|
676
584
|
muted={muted}
|
|
677
585
|
preferCmcd="header"
|
|
@@ -698,13 +606,10 @@ function FilePlayer({
|
|
|
698
606
|
// hides only the bottom bar (center play/pause stays) — the
|
|
699
607
|
// bite-strip card look per Figma. Merged (never replacing) into the
|
|
700
608
|
// sizing style; custom properties need the CSSProperties cast.
|
|
701
|
-
// `--media-object-fit: cover` is media-chrome's documented object-fit
|
|
702
|
-
// var — the `fit="cover"` crop for grid cells / mockups.
|
|
703
609
|
style={{
|
|
704
610
|
width: '100%',
|
|
705
611
|
height: '100%',
|
|
706
612
|
...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
|
|
707
|
-
...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
|
|
708
613
|
}}
|
|
709
614
|
>
|
|
710
615
|
{captionsUrl ? (
|
|
@@ -1100,26 +1100,6 @@ const RichMarkdownInner: React.FC<InnerProps> = ({
|
|
|
1100
1100
|
return <MarkdownImage src={src.trim()} alt={alt} />;
|
|
1101
1101
|
},
|
|
1102
1102
|
|
|
1103
|
-
// Raw <video> tags in stored content (e.g. the hub's blog publisher
|
|
1104
|
-
// injects `<video src class="w-full my-8 rounded-lg" controls>` via
|
|
1105
|
-
// injectVideoInBlogContent) pass through rehypeRaw — route them to the
|
|
1106
|
-
// <Video> SSOT (MuxPlayer) instead of a native element so Mux HLS
|
|
1107
|
-
// manifests play on every browser and playback UX is unified. The
|
|
1108
|
-
// incoming className is forwarded so existing embeds keep their
|
|
1109
|
-
// width/margin/rounding.
|
|
1110
|
-
video: ({ src, poster, className }: any) => {
|
|
1111
|
-
if (!src || typeof src !== 'string' || src.trim() === '') {
|
|
1112
|
-
return null;
|
|
1113
|
-
}
|
|
1114
|
-
return (
|
|
1115
|
-
<div className={`overflow-hidden ${className || 'w-full my-8 rounded-lg'}`}>
|
|
1116
|
-
<div className="w-full aspect-video">
|
|
1117
|
-
<Video kind="file" url={src.trim()} poster={typeof poster === 'string' ? poster : undefined} />
|
|
1118
|
-
</div>
|
|
1119
|
-
</div>
|
|
1120
|
-
);
|
|
1121
|
-
},
|
|
1122
|
-
|
|
1123
1103
|
// Style lists
|
|
1124
1104
|
ul: ({ children }: any) => (
|
|
1125
1105
|
<ul className={`list-disc list-outside my-4 ml-8 space-y-2 ${
|
|
@@ -200,11 +200,7 @@ function cardAwareUrlTransform(url: string, key: string): string {
|
|
|
200
200
|
* 18 logs "tag is unrecognized in this browser" for every unknown tag;
|
|
201
201
|
* React 19 throws on tags with reserved kebab-case forms. We pre-escape
|
|
202
202
|
* to keep the renderer pristine without losing legitimate inline HTML
|
|
203
|
-
* (details/summary, iframe, kbd, mark, etc.).
|
|
204
|
-
* NOT allow-listed: chat strips <video> tags server-side, and all video
|
|
205
|
-
* playback goes through the <Video> SSOT — a stray tag renders as
|
|
206
|
-
* escaped literal text (visible + debuggable) rather than a native
|
|
207
|
-
* player outside the unified pipeline.
|
|
203
|
+
* (details/summary, video, iframe, kbd, mark, etc.).
|
|
208
204
|
*
|
|
209
205
|
* The allow-list mirrors HTML5 + the elements the chat shell wires
|
|
210
206
|
* component overrides for. Kept lower-case; matched case-insensitively.
|
|
@@ -219,8 +215,8 @@ const SAFE_HTML_TAGS = new Set([
|
|
|
219
215
|
'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
|
|
220
216
|
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
|
|
221
217
|
'ul', 'var', 'wbr',
|
|
222
|
-
// Media
|
|
223
|
-
'img', 'picture', 'source', 'audio', 'iframe', 'track',
|
|
218
|
+
// Media
|
|
219
|
+
'img', 'picture', 'source', 'audio', 'video', 'iframe', 'track',
|
|
224
220
|
// Forms (rehype-raw allows them; mostly harmless for chat output)
|
|
225
221
|
'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
|
|
226
222
|
])
|
|
@@ -656,8 +652,8 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
|
|
|
656
652
|
// logs a noisy "tag is unrecognized in this browser" warning AND can
|
|
657
653
|
// crash the SimpleMarkdownRenderer when the tag has a kebab-case
|
|
658
654
|
// form React rejects outright). Allow-listed tags pass through
|
|
659
|
-
// unchanged so legitimate inline HTML (details/summary,
|
|
660
|
-
// etc.) still renders
|
|
655
|
+
// unchanged so legitimate inline HTML (details/summary, video,
|
|
656
|
+
// iframe, etc.) still renders.
|
|
661
657
|
const processedContent = useMemo(
|
|
662
658
|
() => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
|
|
663
659
|
[preprocessContent, content],
|
|
@@ -58,14 +58,6 @@ function getObserverFor(rootMargin: string): IntersectionObserver {
|
|
|
58
58
|
return io;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* Default near-viewport lookahead — the SSOT for "mount media on approach".
|
|
63
|
-
* Shared by this hook's default AND the raw two-way IntersectionObservers in
|
|
64
|
-
* `cards-strip.tsx` / `video-bites-strip.tsx` (which need mount+unmount, not
|
|
65
|
-
* this hook's fire-once semantics, but must agree on the distance).
|
|
66
|
-
*/
|
|
67
|
-
export const NEAR_VIEWPORT_ROOT_MARGIN = '500px';
|
|
68
|
-
|
|
69
61
|
export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
70
62
|
/** Ref to attach to the element you want to gate on visibility. */
|
|
71
63
|
ref: (node: T | null) => void;
|
|
@@ -80,7 +72,7 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
|
80
72
|
* '0px' = strict on-screen detection.
|
|
81
73
|
*/
|
|
82
74
|
export function useNearViewport<T extends Element = HTMLElement>(
|
|
83
|
-
rootMargin: string =
|
|
75
|
+
rootMargin: string = '500px'
|
|
84
76
|
): UseNearViewportResult<T> {
|
|
85
77
|
const [isNear, setIsNear] = useState(false);
|
|
86
78
|
const elRef = useRef<T | null>(null);
|