@flamingo-stack/openframe-frontend-core 0.0.446 → 0.0.447
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-7IC2QCOL.cjs → chunk-2PNWAAU4.cjs} +73 -73
- package/dist/{chunk-7IC2QCOL.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
- package/dist/{chunk-5YQSRQHP.js → chunk-3GBLPPSW.js} +6 -6
- package/dist/{chunk-SAV4U37G.js → chunk-4ONE3HQP.js} +2 -2
- package/dist/{chunk-N4CCB33C.js → chunk-5BJJRPDH.js} +3 -3
- package/dist/{chunk-K6JIBV3X.js → chunk-6TAIXSF5.js} +2 -2
- package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
- package/dist/chunk-AN7MCJTV.cjs.map +1 -0
- package/dist/{chunk-I7IOEXRJ.js → chunk-D2NLNBLH.js} +2 -2
- package/dist/{chunk-SA7AHDS6.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
- package/dist/{chunk-SA7AHDS6.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
- package/dist/{chunk-DH3DUY6Q.cjs → chunk-EZMM2OMP.cjs} +32 -32
- package/dist/{chunk-DH3DUY6Q.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
- package/dist/{chunk-6XZNIPXP.cjs → chunk-FR6NVZVM.cjs} +18 -18
- package/dist/{chunk-6XZNIPXP.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
- package/dist/{chunk-XB6D7B3I.js → chunk-G7QSLGMI.js} +3 -3
- package/dist/{chunk-L276GOMD.cjs → chunk-ITJMUH4Q.cjs} +40 -40
- package/dist/{chunk-L276GOMD.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
- package/dist/{chunk-ZKYEMGMD.cjs → chunk-KYLOPUIH.cjs} +29 -29
- package/dist/{chunk-ZKYEMGMD.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
- package/dist/{chunk-MS5CEBBQ.cjs → chunk-PF6CKZIH.cjs} +5 -5
- package/dist/{chunk-MS5CEBBQ.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
- package/dist/{chunk-BAUWT7PV.cjs → chunk-RNXMP7GB.cjs} +986 -941
- package/dist/chunk-RNXMP7GB.cjs.map +1 -0
- package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
- package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
- package/dist/{chunk-5WX2V6CY.cjs → chunk-S7VMUDUB.cjs} +12 -12
- package/dist/{chunk-5WX2V6CY.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
- package/dist/{chunk-YLMD4UFA.js → chunk-SHC5NFOH.js} +3 -3
- package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
- package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
- package/dist/{chunk-GTHA2SYB.js → chunk-TMZYFYSS.js} +5 -5
- package/dist/{chunk-NQLY27GH.cjs → chunk-WLU37UZO.cjs} +73 -73
- package/dist/{chunk-NQLY27GH.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
- package/dist/{chunk-SCKAD2MY.js → chunk-WMOF3WQT.js} +5 -5
- package/dist/{chunk-3AFV2YIC.cjs → chunk-X4XHW3OV.cjs} +40 -40
- package/dist/{chunk-3AFV2YIC.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
- package/dist/{chunk-N3Y6JYH5.cjs → chunk-XUS75SQW.cjs} +15 -15
- package/dist/{chunk-N3Y6JYH5.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
- package/dist/{chunk-MAB4TQCO.js → chunk-YCSNDUDE.js} +342 -297
- package/dist/chunk-YCSNDUDE.js.map +1 -0
- package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
- package/dist/{chunk-MR7BMJHI.js → chunk-ZQVEB6KT.js} +6 -6
- package/dist/{chunk-BNYI2FVM.js → chunk-ZYB3BYBA.js} +4 -4
- 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 +5 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +4 -2
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/mux-origins.cjs +14 -0
- package/dist/components/features/mux-origins.cjs.map +1 -1
- package/dist/components/features/mux-origins.d.ts +26 -2
- package/dist/components/features/mux-origins.d.ts.map +1 -1
- package/dist/components/features/mux-origins.js +14 -1
- 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 +12 -0
- 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 +1 -11
- package/dist/components/features/video-source-selector.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +14 -1
- 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 +137 -135
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -11
- 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 +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/use-near-viewport.d.ts +7 -0
- package/dist/hooks/use-near-viewport.d.ts.map +1 -1
- package/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
- package/src/components/features/cards-strip.tsx +2 -1
- package/src/components/features/highlight-video-section.tsx +10 -7
- package/src/components/features/media-gallery-manager.tsx +6 -8
- package/src/components/features/mux-origins.ts +40 -2
- package/src/components/features/release-media-manager.tsx +6 -8
- package/src/components/features/use-video-warmup.ts +19 -3
- package/src/components/features/video-bites-strip.tsx +24 -4
- package/src/components/features/video-source-selector.tsx +16 -56
- package/src/components/features/video.tsx +103 -8
- package/src/components/ui/rich-markdown-renderer.tsx +20 -0
- package/src/components/ui/simple-markdown-renderer.tsx +9 -5
- package/src/hooks/use-near-viewport.ts +9 -1
- package/dist/chunk-BAUWT7PV.cjs.map +0 -1
- package/dist/chunk-MAB4TQCO.js.map +0 -1
- package/dist/chunk-VIJARDBM.cjs.map +0 -1
- /package/dist/{chunk-5YQSRQHP.js.map → chunk-3GBLPPSW.js.map} +0 -0
- /package/dist/{chunk-SAV4U37G.js.map → chunk-4ONE3HQP.js.map} +0 -0
- /package/dist/{chunk-N4CCB33C.js.map → chunk-5BJJRPDH.js.map} +0 -0
- /package/dist/{chunk-K6JIBV3X.js.map → chunk-6TAIXSF5.js.map} +0 -0
- /package/dist/{chunk-I7IOEXRJ.js.map → chunk-D2NLNBLH.js.map} +0 -0
- /package/dist/{chunk-XB6D7B3I.js.map → chunk-G7QSLGMI.js.map} +0 -0
- /package/dist/{chunk-YLMD4UFA.js.map → chunk-SHC5NFOH.js.map} +0 -0
- /package/dist/{chunk-GTHA2SYB.js.map → chunk-TMZYFYSS.js.map} +0 -0
- /package/dist/{chunk-SCKAD2MY.js.map → chunk-WMOF3WQT.js.map} +0 -0
- /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
- /package/dist/{chunk-MR7BMJHI.js.map → chunk-ZQVEB6KT.js.map} +0 -0
- /package/dist/{chunk-BNYI2FVM.js.map → chunk-ZYB3BYBA.js.map} +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mux CDN
|
|
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
|
|
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
|
+
}
|
|
@@ -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 <
|
|
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
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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:
|
|
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={
|
|
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={
|
|
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
|
|
4
|
-
import { Upload,
|
|
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
|
-
<
|
|
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
|
|
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
|
|
289
|
-
* `VideoPreviewComponent` is supplied.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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,
|
|
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
|
-
{
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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}
|
|
@@ -34,6 +34,21 @@ 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
|
+
}
|
|
37
52
|
|
|
38
53
|
// =============================================================================
|
|
39
54
|
// Suppress Google Cast SDK loading (CSP-friendly)
|
|
@@ -253,8 +268,19 @@ interface VideoFileProps extends VideoCommonProps {
|
|
|
253
268
|
* seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
|
|
254
269
|
* fragmentless metadata load stays blank). No chrome, no playback, no
|
|
255
270
|
* MuxPlayer cost — the resting facade layer under strip cards when no
|
|
256
|
-
* poster asset exists.
|
|
271
|
+
* poster asset exists. `poster`/`fit`/`className` are honored; all other
|
|
272
|
+
* player props are ignored. */
|
|
257
273
|
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';
|
|
258
284
|
}
|
|
259
285
|
|
|
260
286
|
interface VideoYouTubeProps extends VideoCommonProps {
|
|
@@ -275,6 +301,8 @@ interface VideoAutoProps extends VideoCommonProps {
|
|
|
275
301
|
playOnHover?: boolean;
|
|
276
302
|
playWhenHovered?: boolean;
|
|
277
303
|
firstFrameOnly?: boolean;
|
|
304
|
+
preload?: 'none' | 'metadata' | 'auto';
|
|
305
|
+
fit?: 'contain' | 'cover';
|
|
278
306
|
}
|
|
279
307
|
|
|
280
308
|
export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
@@ -300,7 +328,12 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
300
328
|
minimalControls={props.minimalControls}
|
|
301
329
|
/>
|
|
302
330
|
) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
|
|
303
|
-
<FirstFramePreview
|
|
331
|
+
<FirstFramePreview
|
|
332
|
+
url={url}
|
|
333
|
+
poster={props.poster}
|
|
334
|
+
fit={'fit' in props ? props.fit : undefined}
|
|
335
|
+
className={props.className}
|
|
336
|
+
/>
|
|
304
337
|
) : (
|
|
305
338
|
<FilePlayer
|
|
306
339
|
url={url}
|
|
@@ -313,6 +346,8 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
313
346
|
chromeless={'chromeless' in props ? props.chromeless : undefined}
|
|
314
347
|
playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
|
|
315
348
|
playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
|
|
349
|
+
preload={'preload' in props ? props.preload : undefined}
|
|
350
|
+
fit={'fit' in props ? props.fit : undefined}
|
|
316
351
|
className={props.className}
|
|
317
352
|
/>
|
|
318
353
|
);
|
|
@@ -387,16 +422,30 @@ function wrapWithLayout(
|
|
|
387
422
|
* first frame (also fixes iOS Safari, which stays blank on a fragmentless
|
|
388
423
|
* metadata load). The transparent media background keeps the box showing the
|
|
389
424
|
* card surface (never black) until the frame decodes. */
|
|
390
|
-
function FirstFramePreview({
|
|
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 {
|
|
391
436
|
return (
|
|
392
437
|
<div
|
|
393
438
|
aria-hidden
|
|
394
439
|
className="h-full w-full"
|
|
395
440
|
style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
|
|
396
441
|
>
|
|
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. */}
|
|
397
445
|
<FilePlayer
|
|
398
446
|
url={`${url}#t=0.1`}
|
|
399
|
-
|
|
447
|
+
poster={poster}
|
|
448
|
+
fit={fit}
|
|
400
449
|
muted
|
|
401
450
|
chromeless
|
|
402
451
|
className={className}
|
|
@@ -420,8 +469,11 @@ interface FilePlayerProps {
|
|
|
420
469
|
chromeless?: boolean;
|
|
421
470
|
playOnHover?: boolean;
|
|
422
471
|
playWhenHovered?: boolean;
|
|
423
|
-
/** Media preload hint — the
|
|
472
|
+
/** Media preload hint — see the public `VideoFileProps.preload` JSDoc.
|
|
473
|
+
* When omitted, defaults to 'metadata' ('none' under Save-Data). */
|
|
424
474
|
preload?: 'none' | 'metadata' | 'auto';
|
|
475
|
+
/** Object-fit — 'cover' maps to media-chrome's `--media-object-fit`. */
|
|
476
|
+
fit?: 'contain' | 'cover';
|
|
425
477
|
className?: string;
|
|
426
478
|
}
|
|
427
479
|
|
|
@@ -437,8 +489,21 @@ function FilePlayer({
|
|
|
437
489
|
playOnHover,
|
|
438
490
|
playWhenHovered,
|
|
439
491
|
preload,
|
|
492
|
+
fit,
|
|
440
493
|
className,
|
|
441
494
|
}: 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');
|
|
442
507
|
// True while hover playback is running MUTED because the browser's autoplay
|
|
443
508
|
// policy blocked sound (no user activation yet). Drives the center unmute
|
|
444
509
|
// control — the industry pattern (muted autoplay + explicit unmute button)
|
|
@@ -454,7 +519,22 @@ function FilePlayer({
|
|
|
454
519
|
pause?: () => void;
|
|
455
520
|
muted?: boolean;
|
|
456
521
|
volume?: number;
|
|
522
|
+
addEventListener?: (type: string, listener: () => void) => void;
|
|
523
|
+
removeEventListener?: (type: string, listener: () => void) => void;
|
|
457
524
|
} | 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]);
|
|
458
538
|
// Tracks whether the pointer is STILL over the player. A fast hover-out
|
|
459
539
|
// pauses the in-flight play(), which rejects it with AbortError — that must
|
|
460
540
|
// NOT trigger the muted retry (it would restart playback after the pointer
|
|
@@ -481,6 +561,17 @@ function FilePlayer({
|
|
|
481
561
|
const generation = ++hoverGenerationRef.current;
|
|
482
562
|
const el = hoverPlayerRef.current;
|
|
483
563
|
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
|
+
}
|
|
484
575
|
try {
|
|
485
576
|
el.volume = 0.5;
|
|
486
577
|
if (userHasInteracted) {
|
|
@@ -523,13 +614,14 @@ function FilePlayer({
|
|
|
523
614
|
activationWaiters.add(waiter);
|
|
524
615
|
}
|
|
525
616
|
} catch { /* ignore */ }
|
|
526
|
-
}, []);
|
|
617
|
+
}, [clearActivationWaiter, clearPerfListener]);
|
|
527
618
|
const stopHoverPlayback = useCallback(() => {
|
|
528
619
|
hoverActiveRef.current = false;
|
|
529
620
|
setHoverMutedFallback(false);
|
|
530
621
|
clearActivationWaiter();
|
|
622
|
+
clearPerfListener();
|
|
531
623
|
try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
|
|
532
|
-
}, [clearActivationWaiter]);
|
|
624
|
+
}, [clearActivationWaiter, clearPerfListener]);
|
|
533
625
|
|
|
534
626
|
// Explicit unmute affordance: the click IS the user activation the autoplay
|
|
535
627
|
// policy wants, so unmuting here always succeeds (and the window-level
|
|
@@ -579,7 +671,7 @@ function FilePlayer({
|
|
|
579
671
|
src={url}
|
|
580
672
|
poster={poster || undefined}
|
|
581
673
|
streamType="on-demand"
|
|
582
|
-
preload={
|
|
674
|
+
preload={effectivePreload}
|
|
583
675
|
playsInline
|
|
584
676
|
muted={muted}
|
|
585
677
|
preferCmcd="header"
|
|
@@ -606,10 +698,13 @@ function FilePlayer({
|
|
|
606
698
|
// hides only the bottom bar (center play/pause stays) — the
|
|
607
699
|
// bite-strip card look per Figma. Merged (never replacing) into the
|
|
608
700
|
// 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.
|
|
609
703
|
style={{
|
|
610
704
|
width: '100%',
|
|
611
705
|
height: '100%',
|
|
612
706
|
...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
|
|
707
|
+
...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
|
|
613
708
|
}}
|
|
614
709
|
>
|
|
615
710
|
{captionsUrl ? (
|
|
@@ -1100,6 +1100,26 @@ 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
|
+
|
|
1103
1123
|
// Style lists
|
|
1104
1124
|
ul: ({ children }: any) => (
|
|
1105
1125
|
<ul className={`list-disc list-outside my-4 ml-8 space-y-2 ${
|
|
@@ -200,7 +200,11 @@ 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,
|
|
203
|
+
* (details/summary, iframe, kbd, mark, etc.). `video` is deliberately
|
|
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.
|
|
204
208
|
*
|
|
205
209
|
* The allow-list mirrors HTML5 + the elements the chat shell wires
|
|
206
210
|
* component overrides for. Kept lower-case; matched case-insensitively.
|
|
@@ -215,8 +219,8 @@ const SAFE_HTML_TAGS = new Set([
|
|
|
215
219
|
'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
|
|
216
220
|
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
|
|
217
221
|
'ul', 'var', 'wbr',
|
|
218
|
-
// Media
|
|
219
|
-
'img', 'picture', 'source', 'audio', '
|
|
222
|
+
// Media ('video' intentionally excluded — see the header comment)
|
|
223
|
+
'img', 'picture', 'source', 'audio', 'iframe', 'track',
|
|
220
224
|
// Forms (rehype-raw allows them; mostly harmless for chat output)
|
|
221
225
|
'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
|
|
222
226
|
])
|
|
@@ -652,8 +656,8 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
|
|
|
652
656
|
// logs a noisy "tag is unrecognized in this browser" warning AND can
|
|
653
657
|
// crash the SimpleMarkdownRenderer when the tag has a kebab-case
|
|
654
658
|
// form React rejects outright). Allow-listed tags pass through
|
|
655
|
-
// unchanged so legitimate inline HTML (details/summary,
|
|
656
|
-
//
|
|
659
|
+
// unchanged so legitimate inline HTML (details/summary, iframe,
|
|
660
|
+
// etc.) still renders ('video' is excluded — see SAFE_HTML_TAGS).
|
|
657
661
|
const processedContent = useMemo(
|
|
658
662
|
() => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
|
|
659
663
|
[preprocessContent, content],
|
|
@@ -58,6 +58,14 @@ 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
|
+
|
|
61
69
|
export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
62
70
|
/** Ref to attach to the element you want to gate on visibility. */
|
|
63
71
|
ref: (node: T | null) => void;
|
|
@@ -72,7 +80,7 @@ export interface UseNearViewportResult<T extends Element = HTMLElement> {
|
|
|
72
80
|
* '0px' = strict on-screen detection.
|
|
73
81
|
*/
|
|
74
82
|
export function useNearViewport<T extends Element = HTMLElement>(
|
|
75
|
-
rootMargin: string =
|
|
83
|
+
rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN
|
|
76
84
|
): UseNearViewportResult<T> {
|
|
77
85
|
const [isNear, setIsNear] = useState(false);
|
|
78
86
|
const elRef = useRef<T | null>(null);
|