@flamingo-stack/openframe-frontend-core 0.0.387 → 0.0.388-snapshot.20260709030118
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-SOAWTQXO.cjs → chunk-2JCSORCJ.cjs} +31 -28
- package/dist/chunk-2JCSORCJ.cjs.map +1 -0
- package/dist/{chunk-OLCNA32C.cjs → chunk-3JQUNKWY.cjs} +15 -15
- package/dist/{chunk-OLCNA32C.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
- package/dist/{chunk-OX75ZFN6.js → chunk-44Q3IXZM.js} +2 -2
- package/dist/{chunk-MVY4ZKQR.js → chunk-4OLIDOLO.js} +2 -2
- package/dist/{chunk-ADEP6E3Z.cjs → chunk-7MKU2EB4.cjs} +26 -26
- package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
- package/dist/{chunk-EULRZYQH.js → chunk-BJPQJNSC.js} +9 -14
- package/dist/chunk-BJPQJNSC.js.map +1 -0
- package/dist/{chunk-XGLZK25V.js → chunk-CQXHHVMR.js} +2 -2
- package/dist/{chunk-POJRURML.cjs → chunk-EHTE3CTT.cjs} +37 -37
- package/dist/{chunk-POJRURML.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
- package/dist/{chunk-DKN425XR.cjs → chunk-GNB5ERZP.cjs} +1923 -1227
- package/dist/chunk-GNB5ERZP.cjs.map +1 -0
- package/dist/{chunk-ANPV7GZT.js → chunk-IDWKP5H5.js} +5974 -5278
- package/dist/chunk-IDWKP5H5.js.map +1 -0
- package/dist/{chunk-3KA5L7LC.js → chunk-JSFHTIKA.js} +2 -2
- package/dist/{chunk-GPTS2NXW.js → chunk-KA4WXCN3.js} +12 -9
- package/dist/chunk-KA4WXCN3.js.map +1 -0
- package/dist/{chunk-DU2X2MH2.js → chunk-LFLFQ422.js} +2 -2
- package/dist/{chunk-N2LW5NHS.js → chunk-M6ODI2F5.js} +2 -2
- package/dist/{chunk-DO7LOWFE.cjs → chunk-MIEODAAV.cjs} +9 -9
- package/dist/{chunk-DO7LOWFE.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
- package/dist/{chunk-44ENRZZJ.cjs → chunk-MPOP47ME.cjs} +12 -12
- package/dist/{chunk-44ENRZZJ.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
- package/dist/{chunk-EHVRW3WB.js → chunk-NULFIYK2.js} +2 -2
- package/dist/{chunk-CIH2LZI7.cjs → chunk-OBKCQ7LA.cjs} +62 -62
- package/dist/{chunk-CIH2LZI7.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
- package/dist/{chunk-3L4B7MBD.cjs → chunk-Q4YGG3CL.cjs} +31 -31
- package/dist/{chunk-3L4B7MBD.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
- package/dist/{chunk-SECF2KWQ.cjs → chunk-TQZLC3BQ.cjs} +5 -5
- package/dist/{chunk-SECF2KWQ.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
- package/dist/{chunk-RUHE3RJK.cjs → chunk-VXJBE5NX.cjs} +62 -67
- package/dist/chunk-VXJBE5NX.cjs.map +1 -0
- package/dist/{chunk-OGNKGDHT.js → chunk-WIMXB5EU.js} +4 -4
- package/dist/{chunk-3BPVTHOW.js → chunk-XGOX5UQ2.js} +4 -4
- package/dist/{chunk-SGULN3RX.cjs → chunk-ZFZAHLI4.cjs} +7 -7
- package/dist/{chunk-SGULN3RX.cjs.map → chunk-ZFZAHLI4.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/entity-video-section.d.ts +15 -3
- package/dist/components/features/entity-video-section.d.ts.map +1 -1
- package/dist/components/features/index.cjs +10 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +2 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +11 -3
- package/dist/components/features/video-bites-display.d.ts +12 -26
- package/dist/components/features/video-bites-display.d.ts.map +1 -1
- package/dist/components/features/video-bites-shared.cjs +24 -0
- package/dist/components/features/video-bites-shared.cjs.map +1 -0
- package/dist/components/features/video-bites-shared.d.ts +47 -0
- package/dist/components/features/video-bites-shared.d.ts.map +1 -0
- package/dist/components/features/video-bites-shared.js +20 -0
- package/dist/components/features/video-bites-shared.js.map +1 -0
- package/dist/components/features/video-bites-strip.d.ts +114 -0
- package/dist/components/features/video-bites-strip.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +6 -9
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +25 -0
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +130 -165
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +333 -368
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts +6 -7
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/square-avatar.d.ts +3 -1
- package/dist/components/square-avatar.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/user-display.d.ts +5 -1
- package/dist/components/user-display.d.ts.map +1 -1
- package/dist/index.cjs +10 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -3
- package/dist/types/case-study.d.ts +18 -0
- package/dist/types/case-study.d.ts.map +1 -1
- package/dist/types/video-processing.d.ts +11 -0
- package/dist/types/video-processing.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/components/features/entity-video-section.tsx +26 -5
- package/src/components/features/index.ts +6 -3
- package/src/components/features/video-bites-display.tsx +17 -189
- package/src/components/features/video-bites-shared.ts +62 -0
- package/src/components/features/video-bites-strip.tsx +739 -0
- package/src/components/features/video-ratio-tabs.tsx +15 -19
- package/src/components/features/video.tsx +265 -4
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +10 -9
- package/src/components/shared/product-release/release-detail-page.tsx +12 -18
- package/src/components/square-avatar.tsx +5 -2
- package/src/components/user-display.tsx +14 -4
- package/src/stories/VideoBitesStrip.stories.tsx +82 -0
- package/src/types/case-study.ts +23 -0
- package/src/types/video-processing.ts +11 -0
- package/dist/chunk-ANPV7GZT.js.map +0 -1
- package/dist/chunk-DKN425XR.cjs.map +0 -1
- package/dist/chunk-EULRZYQH.js.map +0 -1
- package/dist/chunk-GPTS2NXW.js.map +0 -1
- package/dist/chunk-RUHE3RJK.cjs.map +0 -1
- package/dist/chunk-SOAWTQXO.cjs.map +0 -1
- /package/dist/{chunk-OX75ZFN6.js.map → chunk-44Q3IXZM.js.map} +0 -0
- /package/dist/{chunk-MVY4ZKQR.js.map → chunk-4OLIDOLO.js.map} +0 -0
- /package/dist/{chunk-XGLZK25V.js.map → chunk-CQXHHVMR.js.map} +0 -0
- /package/dist/{chunk-3KA5L7LC.js.map → chunk-JSFHTIKA.js.map} +0 -0
- /package/dist/{chunk-DU2X2MH2.js.map → chunk-LFLFQ422.js.map} +0 -0
- /package/dist/{chunk-N2LW5NHS.js.map → chunk-M6ODI2F5.js.map} +0 -0
- /package/dist/{chunk-EHVRW3WB.js.map → chunk-NULFIYK2.js.map} +0 -0
- /package/dist/{chunk-OGNKGDHT.js.map → chunk-WIMXB5EU.js.map} +0 -0
- /package/dist/{chunk-3BPVTHOW.js.map → chunk-XGOX5UQ2.js.map} +0 -0
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Aspect-ratio tab + grouping primitives for video
|
|
4
|
+
* Aspect-ratio tab + grouping primitives for video surfaces.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `RatioTabs` + `RATIO_GRID_CLASS` are ADMIN-ONLY now (VideoBitesEditor,
|
|
7
|
+
* VideoLibraryGrid) — the public grid was replaced by `<VideoBitesStrip>`,
|
|
8
|
+
* which consumes only `detectAspectRatio` + `RATIO_TO_CSS_ASPECT` from here.
|
|
9
9
|
*
|
|
10
|
-
* Why these live here in the lib (not the hub):
|
|
11
|
-
*
|
|
12
|
-
* The hub's admin editors re-export from here.
|
|
10
|
+
* Why these live here in the lib (not the hub): the strip and the hub's
|
|
11
|
+
* admin editors share the ratio primitives, and the lib is the SSoT.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import {
|
|
@@ -29,16 +28,13 @@ export type VizardAspectRatio = '9:16' | '16:9' | '1:1';
|
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* Extended VideoTeaser with aspect_ratio metadata from Vizard.
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
* Import this when you need the ratio at compile time
|
|
31
|
+
* The base `VideoTeaser` carries every other persisted bite field
|
|
32
|
+
* (timing/confidence/viral_reason live there); this adds ONLY the
|
|
33
|
+
* ratio. Import this when you need the ratio at compile time —
|
|
34
|
+
* it is the canonical type display/aggregation code extends.
|
|
35
35
|
*/
|
|
36
36
|
export interface VideoTeaserWithRatio extends VideoTeaser {
|
|
37
37
|
aspect_ratio?: VizardAspectRatio;
|
|
38
|
-
confidence?: number;
|
|
39
|
-
viral_reason?: string;
|
|
40
|
-
start_time_ms?: number;
|
|
41
|
-
end_time_ms?: number;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/** Ratio category used for grid layout and tab grouping. */
|
|
@@ -55,11 +51,11 @@ export const RATIO_GRID_CLASS: Record<RatioCategory, string> = {
|
|
|
55
51
|
landscape: 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
|
56
52
|
};
|
|
57
53
|
|
|
58
|
-
/**
|
|
59
|
-
export const
|
|
60
|
-
portrait: '
|
|
61
|
-
square: '
|
|
62
|
-
landscape: '
|
|
54
|
+
/** CSS `aspect-ratio` value per category — sizing for strip cards + placeholders. */
|
|
55
|
+
export const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {
|
|
56
|
+
portrait: '9 / 16',
|
|
57
|
+
square: '1 / 1',
|
|
58
|
+
landscape: '16 / 9',
|
|
63
59
|
};
|
|
64
60
|
|
|
65
61
|
const RATIO_TAB_CONFIG: { key: RatioCategory; label: string }[] = [
|
|
@@ -30,9 +30,10 @@
|
|
|
30
30
|
* layout="native" → intrinsic aspect ratio. Bites grid, blog cards.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
33
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
34
34
|
import MuxPlayer from '@mux/mux-player-react';
|
|
35
35
|
import { PlayIcon } from '../icons-v2-generated/media-playback/play-icon';
|
|
36
|
+
import { VolumeXmarkIcon } from '../icons-v2-generated/audio-and-visual/volume-xmark-icon';
|
|
36
37
|
import { fetchPriorityProp } from '../../utils/fetch-priority';
|
|
37
38
|
|
|
38
39
|
// =============================================================================
|
|
@@ -105,6 +106,32 @@ if (typeof window !== 'undefined' && typeof console !== 'undefined') {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// =============================================================================
|
|
110
|
+
// User-activation tracker (module scope) — Chrome's autoplay policy rejects
|
|
111
|
+
// UNMUTED play() until the user has interacted with the page (click/keydown;
|
|
112
|
+
// pointer MOVEMENT does not count). Hover-preview surfaces use this to pick
|
|
113
|
+
// the right first move (sound vs muted) WITHOUT a rejection round-trip, and
|
|
114
|
+
// to unmute a live muted preview the instant the first gesture lands.
|
|
115
|
+
// =============================================================================
|
|
116
|
+
|
|
117
|
+
let userHasInteracted = false;
|
|
118
|
+
const activationWaiters = new Set<() => void>();
|
|
119
|
+
if (typeof window !== 'undefined') {
|
|
120
|
+
const w = window as unknown as { __VIDEO_ACTIVATION_TRACKED__?: boolean };
|
|
121
|
+
if (!w.__VIDEO_ACTIVATION_TRACKED__) {
|
|
122
|
+
w.__VIDEO_ACTIVATION_TRACKED__ = true;
|
|
123
|
+
const markActivated = () => {
|
|
124
|
+
userHasInteracted = true;
|
|
125
|
+
activationWaiters.forEach(fn => { try { fn(); } catch { /* ignore */ } });
|
|
126
|
+
activationWaiters.clear();
|
|
127
|
+
window.removeEventListener('pointerdown', markActivated, true);
|
|
128
|
+
window.removeEventListener('keydown', markActivated, true);
|
|
129
|
+
};
|
|
130
|
+
window.addEventListener('pointerdown', markActivated, true);
|
|
131
|
+
window.addEventListener('keydown', markActivated, true);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
// =============================================================================
|
|
109
136
|
// URL classifiers (private — `<Video>` is the only consumer)
|
|
110
137
|
// =============================================================================
|
|
@@ -208,6 +235,24 @@ interface VideoFileProps extends VideoCommonProps {
|
|
|
208
235
|
srtContent?: string | null;
|
|
209
236
|
/** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
|
|
210
237
|
captionsUrl?: string | null;
|
|
238
|
+
/** Autoplay muted on mount (forwarded as MuxPlayer `autoPlay="muted"`) — hover-preview surfaces. */
|
|
239
|
+
autoPlay?: boolean;
|
|
240
|
+
/** Loop playback — short bite previews. */
|
|
241
|
+
loop?: boolean;
|
|
242
|
+
/** Hide all player chrome (MuxPlayer `--controls: none`) — chromeless preview mode. */
|
|
243
|
+
chromeless?: boolean;
|
|
244
|
+
/** Play while the pointer hovers the player, pause on leave. Tries WITH
|
|
245
|
+
* sound at 50% volume first (bite-strip behavior); falls back to muted when
|
|
246
|
+
* the browser's autoplay policy rejects unmuted hover playback. */
|
|
247
|
+
playOnHover?: boolean;
|
|
248
|
+
/** CONTROLLED variant of playOnHover: the host owns the hover state (e.g.
|
|
249
|
+
* the bite-strip card, whose overlay also counts as "hovering the card").
|
|
250
|
+
* true → start hover playback, false → pause. When provided, the internal
|
|
251
|
+
* pointer handlers are disabled. */
|
|
252
|
+
playWhenHovered?: boolean;
|
|
253
|
+
/** Hide the bottom control bar, keep only the CENTER play/pause control
|
|
254
|
+
* (bite-strip cards per Figma). */
|
|
255
|
+
centerControlsOnly?: boolean;
|
|
211
256
|
}
|
|
212
257
|
|
|
213
258
|
interface VideoYouTubeProps extends VideoCommonProps {
|
|
@@ -221,6 +266,13 @@ interface VideoAutoProps extends VideoCommonProps {
|
|
|
221
266
|
url: string;
|
|
222
267
|
srtContent?: string | null;
|
|
223
268
|
captionsUrl?: string | null;
|
|
269
|
+
/** See VideoFileProps — no-ops when the URL resolves to the YouTube branch. */
|
|
270
|
+
autoPlay?: boolean;
|
|
271
|
+
loop?: boolean;
|
|
272
|
+
chromeless?: boolean;
|
|
273
|
+
playOnHover?: boolean;
|
|
274
|
+
playWhenHovered?: boolean;
|
|
275
|
+
centerControlsOnly?: boolean;
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
@@ -252,6 +304,12 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
252
304
|
muted={props.muted}
|
|
253
305
|
srtContent={'srtContent' in props ? props.srtContent : null}
|
|
254
306
|
captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
|
|
307
|
+
autoPlay={'autoPlay' in props ? props.autoPlay : undefined}
|
|
308
|
+
loop={'loop' in props ? props.loop : undefined}
|
|
309
|
+
chromeless={'chromeless' in props ? props.chromeless : undefined}
|
|
310
|
+
playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
|
|
311
|
+
playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
|
|
312
|
+
centerControlsOnly={'centerControlsOnly' in props ? props.centerControlsOnly : undefined}
|
|
255
313
|
className={props.className}
|
|
256
314
|
/>
|
|
257
315
|
);
|
|
@@ -308,7 +366,7 @@ function wrapWithLayout(
|
|
|
308
366
|
return <div className="absolute inset-0 w-full h-full">{inner}</div>;
|
|
309
367
|
case 'native':
|
|
310
368
|
default:
|
|
311
|
-
// `native` callers (
|
|
369
|
+
// `native` callers (blog cards etc.) are
|
|
312
370
|
// expected to provide their own aspect-ratio container so the layout
|
|
313
371
|
// primitive doesn't override portrait/square/landscape bites with 16:9.
|
|
314
372
|
return inner;
|
|
@@ -325,6 +383,12 @@ interface FilePlayerProps {
|
|
|
325
383
|
muted?: boolean;
|
|
326
384
|
srtContent?: string | null;
|
|
327
385
|
captionsUrl?: string | null;
|
|
386
|
+
autoPlay?: boolean;
|
|
387
|
+
loop?: boolean;
|
|
388
|
+
chromeless?: boolean;
|
|
389
|
+
playOnHover?: boolean;
|
|
390
|
+
playWhenHovered?: boolean;
|
|
391
|
+
centerControlsOnly?: boolean;
|
|
328
392
|
className?: string;
|
|
329
393
|
}
|
|
330
394
|
|
|
@@ -334,8 +398,138 @@ function FilePlayer({
|
|
|
334
398
|
muted,
|
|
335
399
|
srtContent,
|
|
336
400
|
captionsUrl,
|
|
401
|
+
autoPlay,
|
|
402
|
+
loop,
|
|
403
|
+
chromeless,
|
|
404
|
+
playOnHover,
|
|
405
|
+
playWhenHovered,
|
|
406
|
+
centerControlsOnly,
|
|
337
407
|
className,
|
|
338
408
|
}: FilePlayerProps): React.ReactElement {
|
|
409
|
+
// centerControlsOnly: the center play button shows at REST only — while
|
|
410
|
+
// playing, ALL chrome hides (no pause sign over the hover-preview).
|
|
411
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
412
|
+
// True while hover playback is running MUTED because the browser's autoplay
|
|
413
|
+
// policy blocked sound (no user activation yet). Drives the center unmute
|
|
414
|
+
// control — the industry pattern (muted autoplay + explicit unmute button)
|
|
415
|
+
// instead of silently waiting for a click somewhere.
|
|
416
|
+
const [hoverMutedFallback, setHoverMutedFallback] = useState(false);
|
|
417
|
+
// playOnHover drives the underlying mux-player element imperatively — the
|
|
418
|
+
// element exposes native play()/pause()/muted/volume; the chrome stays as
|
|
419
|
+
// configured. Sound-first: volume 0.5 unmuted, muted fallback when the
|
|
420
|
+
// browser's autoplay policy rejects unmuted hover playback (hover is not a
|
|
421
|
+
// user gesture in Chrome's activation model).
|
|
422
|
+
const hoverPlayerRef = useRef<{
|
|
423
|
+
play?: () => Promise<void> | void;
|
|
424
|
+
pause?: () => void;
|
|
425
|
+
muted?: boolean;
|
|
426
|
+
volume?: number;
|
|
427
|
+
} | null>(null);
|
|
428
|
+
// Tracks whether the pointer is STILL over the player. A fast hover-out
|
|
429
|
+
// pauses the in-flight play(), which rejects it with AbortError — that must
|
|
430
|
+
// NOT trigger the muted retry (it would restart playback after the pointer
|
|
431
|
+
// left, with no visible control to stop it). Only a genuine autoplay-policy
|
|
432
|
+
// rejection (NotAllowedError) while still hovered retries muted.
|
|
433
|
+
const hoverActiveRef = useRef(false);
|
|
434
|
+
// Per-enter generation token: a LATE NotAllowedError from a previous enter
|
|
435
|
+
// must not fire the muted fallback into a newer (intended-sound) session.
|
|
436
|
+
const hoverGenerationRef = useRef(0);
|
|
437
|
+
// This instance's pending activation waiter — pruned on hover-leave,
|
|
438
|
+
// re-enter, and unmount so pre-activation hovers don't accumulate stale
|
|
439
|
+
// closures in the module-level set (they'd otherwise setState against
|
|
440
|
+
// unmounted instances when the first user gesture finally lands).
|
|
441
|
+
const activationWaiterRef = useRef<(() => void) | null>(null);
|
|
442
|
+
const clearActivationWaiter = useCallback(() => {
|
|
443
|
+
if (activationWaiterRef.current) {
|
|
444
|
+
activationWaiters.delete(activationWaiterRef.current);
|
|
445
|
+
activationWaiterRef.current = null;
|
|
446
|
+
}
|
|
447
|
+
}, []);
|
|
448
|
+
useEffect(() => clearActivationWaiter, [clearActivationWaiter]);
|
|
449
|
+
const startHoverPlayback = useCallback(() => {
|
|
450
|
+
hoverActiveRef.current = true;
|
|
451
|
+
const generation = ++hoverGenerationRef.current;
|
|
452
|
+
const el = hoverPlayerRef.current;
|
|
453
|
+
if (!el) return;
|
|
454
|
+
try {
|
|
455
|
+
el.volume = 0.5;
|
|
456
|
+
if (userHasInteracted) {
|
|
457
|
+
// Post-activation: unmuted playback is allowed — play with sound.
|
|
458
|
+
// The NotAllowedError guard stays as a belt-and-suspenders fallback;
|
|
459
|
+
// a fast hover-out's pause() rejects with AbortError and must not
|
|
460
|
+
// restart playback (name mismatch + cleared hoverActiveRef).
|
|
461
|
+
el.muted = false;
|
|
462
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.((err: unknown) => {
|
|
463
|
+
const name = (err as { name?: string } | null)?.name;
|
|
464
|
+
if (
|
|
465
|
+
name === 'NotAllowedError' &&
|
|
466
|
+
hoverActiveRef.current &&
|
|
467
|
+
generation === hoverGenerationRef.current
|
|
468
|
+
) {
|
|
469
|
+
try {
|
|
470
|
+
el.muted = true;
|
|
471
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
|
|
472
|
+
setHoverMutedFallback(true);
|
|
473
|
+
} catch { /* give up silently */ }
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
// Pre-activation: unmuted WOULD be rejected (hover isn't a gesture in
|
|
478
|
+
// Chrome's activation model) — start muted immediately with no
|
|
479
|
+
// rejection round-trip, and UNMUTE LIVE the instant the user's first
|
|
480
|
+
// click/keydown lands anywhere while this hover is still active.
|
|
481
|
+
el.muted = true;
|
|
482
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
|
|
483
|
+
setHoverMutedFallback(true);
|
|
484
|
+
clearActivationWaiter();
|
|
485
|
+
const waiter = () => {
|
|
486
|
+
activationWaiterRef.current = null;
|
|
487
|
+
if (hoverActiveRef.current && generation === hoverGenerationRef.current) {
|
|
488
|
+
try { el.muted = false; el.volume = 0.5; } catch { /* ignore */ }
|
|
489
|
+
setHoverMutedFallback(false);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
activationWaiterRef.current = waiter;
|
|
493
|
+
activationWaiters.add(waiter);
|
|
494
|
+
}
|
|
495
|
+
} catch { /* ignore */ }
|
|
496
|
+
}, []);
|
|
497
|
+
const stopHoverPlayback = useCallback(() => {
|
|
498
|
+
hoverActiveRef.current = false;
|
|
499
|
+
setHoverMutedFallback(false);
|
|
500
|
+
clearActivationWaiter();
|
|
501
|
+
try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
|
|
502
|
+
}, [clearActivationWaiter]);
|
|
503
|
+
|
|
504
|
+
// Explicit unmute affordance: the click IS the user activation the autoplay
|
|
505
|
+
// policy wants, so unmuting here always succeeds (and the window-level
|
|
506
|
+
// activation listener flips the module flag for every other player too).
|
|
507
|
+
const unmuteNow = useCallback((e: React.MouseEvent) => {
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
const el = hoverPlayerRef.current;
|
|
511
|
+
try {
|
|
512
|
+
if (el) {
|
|
513
|
+
el.muted = false;
|
|
514
|
+
el.volume = 0.5;
|
|
515
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
|
|
516
|
+
}
|
|
517
|
+
} catch { /* ignore */ }
|
|
518
|
+
setHoverMutedFallback(false);
|
|
519
|
+
}, []);
|
|
520
|
+
|
|
521
|
+
// Controlled hover mode (playWhenHovered): the HOST owns hover detection —
|
|
522
|
+
// e.g. the bite-strip card, where the detail overlay is part of the card and
|
|
523
|
+
// must NOT pause playback when the pointer moves onto it.
|
|
524
|
+
const hoverControlled = typeof playWhenHovered === 'boolean';
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!hoverControlled) return;
|
|
527
|
+
if (playWhenHovered) startHoverPlayback();
|
|
528
|
+
else stopHoverPlayback();
|
|
529
|
+
}, [hoverControlled, playWhenHovered, startHoverPlayback, stopHoverPlayback]);
|
|
530
|
+
|
|
531
|
+
const handleHoverEnter = playOnHover && !hoverControlled ? startHoverPlayback : undefined;
|
|
532
|
+
const handleHoverLeave = playOnHover && !hoverControlled ? stopHoverPlayback : undefined;
|
|
339
533
|
// Raw SRT text is unusable without a custom overlay — and we just deleted
|
|
340
534
|
// the 900-LOC custom-controls layer that owned that overlay. Consumers
|
|
341
535
|
// pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
|
|
@@ -349,8 +543,11 @@ function FilePlayer({
|
|
|
349
543
|
);
|
|
350
544
|
}
|
|
351
545
|
|
|
352
|
-
|
|
546
|
+
const player = (
|
|
353
547
|
<MuxPlayer
|
|
548
|
+
ref={hoverPlayerRef as React.Ref<never>}
|
|
549
|
+
onPlay={centerControlsOnly ? () => setIsPlaying(true) : undefined}
|
|
550
|
+
onPause={centerControlsOnly ? () => setIsPlaying(false) : undefined}
|
|
354
551
|
src={url}
|
|
355
552
|
poster={poster || undefined}
|
|
356
553
|
streamType="on-demand"
|
|
@@ -365,6 +562,8 @@ function FilePlayer({
|
|
|
365
562
|
// is ever undefined on a `data-app-type` we haven't themed yet.
|
|
366
563
|
// NEVER let Mux pink leak onto a non-Flamingo platform.
|
|
367
564
|
accentColor="var(--ods-accent, var(--color-accent-primary))"
|
|
565
|
+
autoPlay={autoPlay ? 'muted' : undefined}
|
|
566
|
+
loop={loop}
|
|
368
567
|
className={className}
|
|
369
568
|
// Fill the wrapping aspect-ratio container instead of MuxPlayer's
|
|
370
569
|
// intrinsic size. Without this, MuxPlayer renders at its default
|
|
@@ -373,7 +572,25 @@ function FilePlayer({
|
|
|
373
572
|
// flickers and grows" CLS we're killing. With `aspect-video` on
|
|
374
573
|
// the centered wrapper and `width/height: 100%` here, the box is
|
|
375
574
|
// 16:9 from first paint and stays put.
|
|
376
|
-
|
|
575
|
+
// `--controls: none` is media-chrome's kill switch for ALL player
|
|
576
|
+
// chrome — the chromeless preview mode. `--bottom-controls: none`
|
|
577
|
+
// hides only the bottom bar (center play/pause stays) — the
|
|
578
|
+
// bite-strip card look per Figma. Merged (never replacing) into the
|
|
579
|
+
// sizing style; custom properties need the CSSProperties cast.
|
|
580
|
+
style={{
|
|
581
|
+
width: '100%',
|
|
582
|
+
height: '100%',
|
|
583
|
+
...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
|
|
584
|
+
...(centerControlsOnly && !chromeless
|
|
585
|
+
? ({
|
|
586
|
+
'--bottom-controls': 'none',
|
|
587
|
+
'--top-controls': 'none',
|
|
588
|
+
// While playing, hide the center control too — no pause sign
|
|
589
|
+
// over the hover-preview; it reappears when playback pauses.
|
|
590
|
+
...(isPlaying ? { '--center-controls': 'none' } : {}),
|
|
591
|
+
} as React.CSSProperties)
|
|
592
|
+
: {}),
|
|
593
|
+
}}
|
|
377
594
|
>
|
|
378
595
|
{captionsUrl ? (
|
|
379
596
|
<track
|
|
@@ -386,6 +603,50 @@ function FilePlayer({
|
|
|
386
603
|
) : null}
|
|
387
604
|
</MuxPlayer>
|
|
388
605
|
);
|
|
606
|
+
|
|
607
|
+
// Center unmute control — shown while hover playback runs muted because the
|
|
608
|
+
// autoplay policy blocked sound. Best-practice pattern (Mux / FB / IG):
|
|
609
|
+
// muted autoplay + an explicit unmute affordance, never forced sound.
|
|
610
|
+
// Styled to match media-chrome's center controls exactly (the play glyph in
|
|
611
|
+
// the same slot): plain large white glyph, no circle/border/background,
|
|
612
|
+
// slight dim on hover — so unmute reads as just another center control.
|
|
613
|
+
const unmuteBadge = hoverMutedFallback ? (
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
aria-label="Unmute"
|
|
617
|
+
title="Unmute"
|
|
618
|
+
onClick={unmuteNow}
|
|
619
|
+
className="absolute inset-0 z-10 m-auto flex h-14 w-14 items-center justify-center text-ods-text-primary transition-opacity hover:opacity-75"
|
|
620
|
+
>
|
|
621
|
+
<VolumeXmarkIcon size={56} />
|
|
622
|
+
</button>
|
|
623
|
+
) : null;
|
|
624
|
+
|
|
625
|
+
// MuxPlayerProps has no pointer-event props — the hover-play handlers live
|
|
626
|
+
// on a full-size wrapper instead (only in UNCONTROLLED playOnHover mode;
|
|
627
|
+
// controlled playWhenHovered hosts own their hover detection). Either
|
|
628
|
+
// hover-capable mode gets a relative wrapper so the unmute badge can dock.
|
|
629
|
+
if (playOnHover && !hoverControlled) {
|
|
630
|
+
return (
|
|
631
|
+
<div
|
|
632
|
+
className="relative w-full h-full"
|
|
633
|
+
onPointerEnter={handleHoverEnter}
|
|
634
|
+
onPointerLeave={handleHoverLeave}
|
|
635
|
+
>
|
|
636
|
+
{player}
|
|
637
|
+
{unmuteBadge}
|
|
638
|
+
</div>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
if (hoverControlled) {
|
|
642
|
+
return (
|
|
643
|
+
<div className="relative w-full h-full">
|
|
644
|
+
{player}
|
|
645
|
+
{unmuteBadge}
|
|
646
|
+
</div>
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return player;
|
|
389
650
|
}
|
|
390
651
|
|
|
391
652
|
// -----------------------------------------------------------------------------
|
|
@@ -26,7 +26,8 @@ import { useRouter } from '../../embed-shims'
|
|
|
26
26
|
import { PageShell } from '../layout/article-detail-layout'
|
|
27
27
|
import { DetailPageSkeleton } from '../shared/detail-page-skeleton'
|
|
28
28
|
import { EntityVideoSection } from '../features/entity-video-section'
|
|
29
|
-
import {
|
|
29
|
+
import { VideoBitesStrip } from '../features/video-bites-strip'
|
|
30
|
+
import { toStripProfile } from '../features/video-bites-shared'
|
|
30
31
|
import { useVideoWarmup } from '../features/use-video-warmup'
|
|
31
32
|
import { getCaptionsUrl } from '../features/captions-url'
|
|
32
33
|
import { RichMarkdownRenderer } from '../ui/rich-markdown-renderer'
|
|
@@ -208,14 +209,14 @@ export function OnboardingGuideDetailView({
|
|
|
208
209
|
</div>
|
|
209
210
|
)}
|
|
210
211
|
|
|
211
|
-
{/* Video Bites */}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
{/* Video Bites — unified strip; renders nothing when no published bites. */}
|
|
213
|
+
<VideoBitesStrip
|
|
214
|
+
bites={(guide.video_bites as VideoTeaser[] | null) ?? []}
|
|
215
|
+
filterPublished={true}
|
|
216
|
+
showTitle={false}
|
|
217
|
+
profile={toStripProfile(guide.author)}
|
|
218
|
+
href={`/onboarding-guides/${guide.slug}`}
|
|
219
|
+
/>
|
|
219
220
|
|
|
220
221
|
{/* End-of-article author byline (avatar + linked name + bio) — the
|
|
221
222
|
author DESCRIPTION block every article-shaped detail page renders.
|
|
@@ -25,6 +25,11 @@ import { DetailPageSkeleton } from '../detail-page-skeleton';
|
|
|
25
25
|
import type { ChangelogEntry } from '../../../types/product-release';
|
|
26
26
|
import type { TagAssoc } from '../../../types/blog';
|
|
27
27
|
import type { VideoTeaser } from '../../../types/video-processing';
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_VIDEO_BITES_TITLE,
|
|
30
|
+
toStripProfile,
|
|
31
|
+
type VideoBiteStripProfile,
|
|
32
|
+
} from '../../features/video-bites-shared';
|
|
28
33
|
|
|
29
34
|
// Types for injectable components
|
|
30
35
|
export interface MarkdownRendererProps {
|
|
@@ -59,12 +64,6 @@ export interface DeliverySectionProps {
|
|
|
59
64
|
isLoading: boolean;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
export interface VideoSectionProps {
|
|
63
|
-
bites: VideoTeaser[];
|
|
64
|
-
title?: string;
|
|
65
|
-
filterPublished?: boolean;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
67
|
// Type for the useRelease hook result
|
|
69
68
|
export interface UseReleaseResult {
|
|
70
69
|
data: unknown;
|
|
@@ -83,6 +82,10 @@ export interface VideoDisplaySectionProps {
|
|
|
83
82
|
videoBites?: VideoTeaser[];
|
|
84
83
|
bitesTitle?: string;
|
|
85
84
|
filterPublishedBites?: boolean;
|
|
85
|
+
/** Profile shown in the bites strip's hover overlay (kit sources it from release.author). */
|
|
86
|
+
bitesProfile?: VideoBiteStripProfile | null;
|
|
87
|
+
/** Overlay-footer navigation target (the release the bites originated from). */
|
|
88
|
+
bitesHref?: string;
|
|
86
89
|
srtContent?: string | null;
|
|
87
90
|
captionsUrl?: string | null;
|
|
88
91
|
}
|
|
@@ -96,7 +99,6 @@ export interface ReleaseDetailPageProps {
|
|
|
96
99
|
MarkdownRenderer?: ComponentType<MarkdownRendererProps>;
|
|
97
100
|
RoadmapSection?: ComponentType<RoadmapSectionProps>;
|
|
98
101
|
DeliverySection?: ComponentType<DeliverySectionProps>;
|
|
99
|
-
VideoSection?: ComponentType<VideoSectionProps>;
|
|
100
102
|
/** Injectable video display section with tabs for full/highlight video + summary + bites */
|
|
101
103
|
VideoDisplaySection?: ComponentType<VideoDisplaySectionProps>;
|
|
102
104
|
// API endpoints for fetching linked tasks
|
|
@@ -134,7 +136,6 @@ export function ReleaseDetailPage({
|
|
|
134
136
|
MarkdownRenderer = DefaultMarkdownRenderer,
|
|
135
137
|
RoadmapSection,
|
|
136
138
|
DeliverySection,
|
|
137
|
-
VideoSection,
|
|
138
139
|
VideoDisplaySection,
|
|
139
140
|
roadmapApiEndpoint = '/api/roadmap',
|
|
140
141
|
deliveryApiEndpoint = '/api/delivery',
|
|
@@ -335,8 +336,10 @@ export function ReleaseDetailPage({
|
|
|
335
336
|
highlightVideoThumbnail={highlightVideoThumbnail}
|
|
336
337
|
title={releaseTitle}
|
|
337
338
|
videoBites={videoBites}
|
|
338
|
-
bitesTitle=
|
|
339
|
+
bitesTitle={DEFAULT_VIDEO_BITES_TITLE}
|
|
339
340
|
filterPublishedBites={true}
|
|
341
|
+
bitesProfile={toStripProfile(author)}
|
|
342
|
+
bitesHref={typeof release.slug === 'string' ? `/releases/${release.slug}` : undefined}
|
|
340
343
|
srtContent={release?.srt_content as string | null | undefined}
|
|
341
344
|
captionsUrl={release?.captionsUrl as string | undefined}
|
|
342
345
|
/>
|
|
@@ -434,15 +437,6 @@ export function ReleaseDetailPage({
|
|
|
434
437
|
MarkdownRenderer={MarkdownRenderer}
|
|
435
438
|
/>
|
|
436
439
|
|
|
437
|
-
{/* Video Bites Section - Only when VideoDisplaySection is not handling it */}
|
|
438
|
-
{!VideoDisplaySection && VideoSection && videoBites && videoBites.length > 0 && (
|
|
439
|
-
<VideoSection
|
|
440
|
-
bites={videoBites}
|
|
441
|
-
title="Video Clips"
|
|
442
|
-
filterPublished={true}
|
|
443
|
-
/>
|
|
444
|
-
)}
|
|
445
|
-
|
|
446
440
|
{/* Related Roadmap Items */}
|
|
447
441
|
{RoadmapSection && (roadmapLoading || roadmapTasks.length > 0) && (
|
|
448
442
|
<div className="space-y-4 w-full">
|
|
@@ -13,6 +13,8 @@ interface SquareAvatarProps {
|
|
|
13
13
|
size?: number;
|
|
14
14
|
/** If true, avatar takes full width with square aspect ratio */
|
|
15
15
|
fullWidth?: boolean;
|
|
16
|
+
/** Corner treatment. `'square'` = rounded-lg (default, historical look); `'round'` = fully circular. */
|
|
17
|
+
shape?: 'square' | 'round';
|
|
16
18
|
className?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -20,7 +22,7 @@ interface SquareAvatarProps {
|
|
|
20
22
|
* Square avatar with rounded edges used across cards / dashboards.
|
|
21
23
|
* Automatically shows image (via proxied URL) or initials fallback.
|
|
22
24
|
*/
|
|
23
|
-
export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, className }: SquareAvatarProps) {
|
|
25
|
+
export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, shape = 'square', className }: SquareAvatarProps) {
|
|
24
26
|
const initials = React.useMemo(() => {
|
|
25
27
|
if (!fallbackName) return '';
|
|
26
28
|
return fallbackName
|
|
@@ -36,7 +38,8 @@ export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = fa
|
|
|
36
38
|
return (
|
|
37
39
|
<div
|
|
38
40
|
className={cn(
|
|
39
|
-
'
|
|
41
|
+
'border border-ods-border flex items-center justify-center overflow-hidden bg-ods-bg-secondary',
|
|
42
|
+
shape === 'round' ? 'rounded-full' : 'rounded-lg',
|
|
40
43
|
fullWidth ? 'w-full aspect-square' : 'flex-shrink-0',
|
|
41
44
|
className,
|
|
42
45
|
)}
|
|
@@ -11,6 +11,10 @@ interface UserDisplayProps {
|
|
|
11
11
|
subtitle?: string | null;
|
|
12
12
|
/** Avatar size in px (defaults 32) */
|
|
13
13
|
size?: number;
|
|
14
|
+
/** Avatar corner treatment, forwarded to SquareAvatar. Default 'square'. */
|
|
15
|
+
shape?: 'square' | 'round';
|
|
16
|
+
/** Compact typography (14px/20px rows — video-bite overlay density). */
|
|
17
|
+
compact?: boolean;
|
|
14
18
|
className?: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -18,16 +22,22 @@ interface UserDisplayProps {
|
|
|
18
22
|
* Reusable horizontal avatar + name (+ optional subtitle) row that follows
|
|
19
23
|
* the visual pattern used in CommentCard headers.
|
|
20
24
|
*/
|
|
21
|
-
export function UserDisplay({ name, avatarUrl, subtitle, size = 32, className }: UserDisplayProps) {
|
|
25
|
+
export function UserDisplay({ name, avatarUrl, subtitle, size = 32, shape = 'square', compact = false, className }: UserDisplayProps) {
|
|
22
26
|
return (
|
|
23
27
|
<div className={cn('flex items-center gap-2 min-w-0', className)}>
|
|
24
|
-
<SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} />
|
|
28
|
+
<SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} shape={shape} />
|
|
25
29
|
<div className="min-w-0 flex-1">
|
|
26
|
-
<p className=
|
|
30
|
+
<p className={cn(
|
|
31
|
+
"font-['DM_Sans'] text-ods-text-primary truncate",
|
|
32
|
+
compact ? 'text-xs leading-4 font-medium' : 'text-lg leading-[22px]',
|
|
33
|
+
)}>
|
|
27
34
|
{name}
|
|
28
35
|
</p>
|
|
29
36
|
{subtitle && (
|
|
30
|
-
<span className=
|
|
37
|
+
<span className={cn(
|
|
38
|
+
"font-['DM_Sans'] text-ods-text-secondary truncate block",
|
|
39
|
+
compact ? 'text-xs leading-4 font-medium' : 'text-md leading-[16px]',
|
|
40
|
+
)}>
|
|
31
41
|
{subtitle}
|
|
32
42
|
</span>
|
|
33
43
|
)}
|