@flamingo-stack/openframe-frontend-core 0.0.581 → 0.0.582-1960.5386.1
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-R5JVRFSW.js → chunk-4KWAHIYA.js} +3 -3
- package/dist/{chunk-KAEVE7W2.js → chunk-646JEAY4.js} +2 -2
- package/dist/{chunk-GAHKWML7.js → chunk-6EUYXDV3.js} +6 -6
- package/dist/{chunk-HVSZYUGP.js → chunk-7HHRJTIY.js} +5 -5
- package/dist/{chunk-JW53WPB5.js → chunk-A3SVEK7A.js} +3 -3
- package/dist/{chunk-6U45VX5J.cjs → chunk-A5VD6R7N.cjs} +43 -43
- package/dist/{chunk-6U45VX5J.cjs.map → chunk-A5VD6R7N.cjs.map} +1 -1
- package/dist/{chunk-BTKMTVN5.cjs → chunk-ALSLHGBH.cjs} +4 -4
- package/dist/{chunk-BTKMTVN5.cjs.map → chunk-ALSLHGBH.cjs.map} +1 -1
- package/dist/{chunk-SEZ25E22.cjs → chunk-BQPCCBGO.cjs} +10 -10
- package/dist/{chunk-SEZ25E22.cjs.map → chunk-BQPCCBGO.cjs.map} +1 -1
- package/dist/{chunk-LWDIDSCF.js → chunk-BUOYMIFJ.js} +3 -3
- package/dist/{chunk-3GXNCJZI.js → chunk-CR7GPMCM.js} +3 -3
- package/dist/{chunk-U3W2ZVN4.cjs → chunk-DGZPX2G3.cjs} +159 -159
- package/dist/{chunk-U3W2ZVN4.cjs.map → chunk-DGZPX2G3.cjs.map} +1 -1
- package/dist/{chunk-PBRXT7TD.cjs → chunk-DQVM4FTL.cjs} +10 -10
- package/dist/{chunk-PBRXT7TD.cjs.map → chunk-DQVM4FTL.cjs.map} +1 -1
- package/dist/{chunk-HJVKPG55.js → chunk-F227VMPL.js} +125 -15
- package/dist/{chunk-HJVKPG55.js.map → chunk-F227VMPL.js.map} +1 -1
- package/dist/{chunk-EZVABFEO.js → chunk-FRWQ67UM.js} +2 -2
- package/dist/{chunk-DSPQVZFE.cjs → chunk-FYVL6A4Z.cjs} +24 -24
- package/dist/{chunk-DSPQVZFE.cjs.map → chunk-FYVL6A4Z.cjs.map} +1 -1
- package/dist/{chunk-VXUUSLD5.js → chunk-GD3OAM4T.js} +2 -2
- package/dist/{chunk-ESWXXWCL.js → chunk-HQF7VE7B.js} +2 -2
- package/dist/{chunk-VDRCKHML.cjs → chunk-J5I3TKAH.cjs} +3 -3
- package/dist/{chunk-VDRCKHML.cjs.map → chunk-J5I3TKAH.cjs.map} +1 -1
- package/dist/{chunk-GUC4JK6O.cjs → chunk-K5SCFOD5.cjs} +9 -9
- package/dist/{chunk-GUC4JK6O.cjs.map → chunk-K5SCFOD5.cjs.map} +1 -1
- package/dist/{chunk-UKHM2S6I.cjs → chunk-KAOXOYRT.cjs} +15 -15
- package/dist/{chunk-UKHM2S6I.cjs.map → chunk-KAOXOYRT.cjs.map} +1 -1
- package/dist/{chunk-IHFQ5VVR.js → chunk-KBOBPKMU.js} +5 -5
- package/dist/{chunk-GMQEHDN2.cjs → chunk-L4E2TOSF.cjs} +5 -5
- package/dist/{chunk-GMQEHDN2.cjs.map → chunk-L4E2TOSF.cjs.map} +1 -1
- package/dist/{chunk-YU2UJPAT.cjs → chunk-LOJ5KQ6B.cjs} +466 -356
- package/dist/chunk-LOJ5KQ6B.cjs.map +1 -0
- package/dist/{chunk-M4KQWGEL.cjs → chunk-ORQQZQBB.cjs} +32 -32
- package/dist/{chunk-M4KQWGEL.cjs.map → chunk-ORQQZQBB.cjs.map} +1 -1
- package/dist/{chunk-ZEZMGXYV.js → chunk-QDSVTIO5.js} +5 -5
- package/dist/{chunk-KNBXZ22O.cjs → chunk-R3J2ERH2.cjs} +3 -3
- package/dist/{chunk-KNBXZ22O.cjs.map → chunk-R3J2ERH2.cjs.map} +1 -1
- package/dist/{chunk-WHFEG6UU.cjs → chunk-RMOUILHT.cjs} +22 -22
- package/dist/{chunk-WHFEG6UU.cjs.map → chunk-RMOUILHT.cjs.map} +1 -1
- package/dist/{chunk-OKA3DJJ3.js → chunk-SKGMU7TZ.js} +2 -2
- package/dist/{chunk-7CP5KY5W.js → chunk-SVQVAGZK.js} +3 -3
- package/dist/{chunk-W2TXSZTM.cjs → chunk-T32XVOFP.cjs} +21 -21
- package/dist/{chunk-W2TXSZTM.cjs.map → chunk-T32XVOFP.cjs.map} +1 -1
- package/dist/{chunk-GVM37TRU.js → chunk-U2ZRM3YB.js} +4 -4
- package/dist/{chunk-AIALG25H.cjs → chunk-WQ236GO3.cjs} +69 -69
- package/dist/{chunk-AIALG25H.cjs.map → chunk-WQ236GO3.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 +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- 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/video.d.ts +12 -0
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +123 -121
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +15 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +21 -21
- package/dist/components/meeting-scheduler/index.js +2 -2
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/index.cjs +9 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/features/__tests__/video-playback-failure.test.tsx +250 -0
- package/src/components/features/video.tsx +198 -16
- package/dist/chunk-YU2UJPAT.cjs.map +0 -1
- /package/dist/{chunk-R5JVRFSW.js.map → chunk-4KWAHIYA.js.map} +0 -0
- /package/dist/{chunk-KAEVE7W2.js.map → chunk-646JEAY4.js.map} +0 -0
- /package/dist/{chunk-GAHKWML7.js.map → chunk-6EUYXDV3.js.map} +0 -0
- /package/dist/{chunk-HVSZYUGP.js.map → chunk-7HHRJTIY.js.map} +0 -0
- /package/dist/{chunk-JW53WPB5.js.map → chunk-A3SVEK7A.js.map} +0 -0
- /package/dist/{chunk-LWDIDSCF.js.map → chunk-BUOYMIFJ.js.map} +0 -0
- /package/dist/{chunk-3GXNCJZI.js.map → chunk-CR7GPMCM.js.map} +0 -0
- /package/dist/{chunk-EZVABFEO.js.map → chunk-FRWQ67UM.js.map} +0 -0
- /package/dist/{chunk-VXUUSLD5.js.map → chunk-GD3OAM4T.js.map} +0 -0
- /package/dist/{chunk-ESWXXWCL.js.map → chunk-HQF7VE7B.js.map} +0 -0
- /package/dist/{chunk-IHFQ5VVR.js.map → chunk-KBOBPKMU.js.map} +0 -0
- /package/dist/{chunk-ZEZMGXYV.js.map → chunk-QDSVTIO5.js.map} +0 -0
- /package/dist/{chunk-OKA3DJJ3.js.map → chunk-SKGMU7TZ.js.map} +0 -0
- /package/dist/{chunk-7CP5KY5W.js.map → chunk-SVQVAGZK.js.map} +0 -0
- /package/dist/{chunk-GVM37TRU.js.map → chunk-U2ZRM3YB.js.map} +0 -0
|
@@ -35,6 +35,7 @@ import type React from 'react';
|
|
|
35
35
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
36
36
|
import { useAuthedAssetSrc } from '../../hooks/use-authed-asset-src';
|
|
37
37
|
import { fetchPriorityProp } from '../../utils/fetch-priority';
|
|
38
|
+
import { Button } from '../ui/button';
|
|
38
39
|
import { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';
|
|
39
40
|
import { saveDataEnabled } from './use-video-warmup';
|
|
40
41
|
import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
|
|
@@ -280,6 +281,76 @@ export function extractYouTubeId(url: string): string | null {
|
|
|
280
281
|
return m ? m[1] : null;
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
// =============================================================================
|
|
285
|
+
// Playback-failure handling
|
|
286
|
+
// =============================================================================
|
|
287
|
+
//
|
|
288
|
+
// A stalled or errored player leaves the user on an endless spinner with no way
|
|
289
|
+
// out. Every failure surfaces a retry (`VideoErrorOverlay`) and is reported on
|
|
290
|
+
// a `window` CustomEvent the host subscribes to — no vendor analytics global is
|
|
291
|
+
// touched from here; this package ships to consumers that load different (or
|
|
292
|
+
// no) analytics.
|
|
293
|
+
|
|
294
|
+
export type VideoFailureKind = 'file' | 'youtube';
|
|
295
|
+
/** `stall`: buffering that never cleared while playback was expected. */
|
|
296
|
+
export type VideoFailureReason = 'error' | 'stall';
|
|
297
|
+
|
|
298
|
+
export interface VideoPlaybackFailureDetail {
|
|
299
|
+
kind: VideoFailureKind;
|
|
300
|
+
/** File URL or YouTube video id — enough to group failures by source. */
|
|
301
|
+
src: string;
|
|
302
|
+
reason: VideoFailureReason;
|
|
303
|
+
/** YouTube IFrame API error code, when the embed reported one. */
|
|
304
|
+
errorCode?: number;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const VIDEO_PLAYBACK_FAILED_EVENT = 'flamingo:video-playback-failed';
|
|
308
|
+
|
|
309
|
+
function reportPlaybackFailure(detail: VideoPlaybackFailureDetail): void {
|
|
310
|
+
if (typeof window === 'undefined') return;
|
|
311
|
+
try {
|
|
312
|
+
window.dispatchEvent(new CustomEvent(VIDEO_PLAYBACK_FAILED_EVENT, { detail }));
|
|
313
|
+
} catch {
|
|
314
|
+
// Analytics must never break playback recovery.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Buffering longer than this while playback is expected reads as "broken", not
|
|
319
|
+
// "loading" — surface a retry instead of spinning forever. This is what an
|
|
320
|
+
// `error` callback alone misses: a video that played, then stalled mid-way.
|
|
321
|
+
const VIDEO_STALL_TIMEOUT_MS = 15_000;
|
|
322
|
+
|
|
323
|
+
/** `watchOnYouTubeId` adds the one recovery that survives an embed the owner
|
|
324
|
+
* disabled: opening the video on YouTube itself. */
|
|
325
|
+
function VideoErrorOverlay({
|
|
326
|
+
onRetry,
|
|
327
|
+
watchOnYouTubeId,
|
|
328
|
+
}: {
|
|
329
|
+
onRetry: () => void;
|
|
330
|
+
watchOnYouTubeId?: string;
|
|
331
|
+
}): React.ReactElement {
|
|
332
|
+
return (
|
|
333
|
+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-[var(--spacing-system-sf)] bg-ods-overlay p-[var(--spacing-system-lf)] text-center">
|
|
334
|
+
<p className="text-ods-text-secondary text-h6">This video failed to play.</p>
|
|
335
|
+
<div className="flex items-center gap-[var(--spacing-system-sf)]">
|
|
336
|
+
<Button variant="outline" size="small" onClick={onRetry}>
|
|
337
|
+
Try again
|
|
338
|
+
</Button>
|
|
339
|
+
{watchOnYouTubeId ? (
|
|
340
|
+
<Button
|
|
341
|
+
variant="transparent"
|
|
342
|
+
size="small"
|
|
343
|
+
href={`https://www.youtube.com/watch?v=${watchOnYouTubeId}`}
|
|
344
|
+
openInNewTab
|
|
345
|
+
>
|
|
346
|
+
Watch on YouTube
|
|
347
|
+
</Button>
|
|
348
|
+
) : null}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
283
354
|
// =============================================================================
|
|
284
355
|
// Props
|
|
285
356
|
// =============================================================================
|
|
@@ -690,11 +761,13 @@ function FilePlayer({
|
|
|
690
761
|
const hoverPlayerRef = useRef<{
|
|
691
762
|
play?: () => Promise<void> | void;
|
|
692
763
|
pause?: () => void;
|
|
764
|
+
load?: () => void;
|
|
693
765
|
muted?: boolean;
|
|
694
766
|
volume?: number;
|
|
695
767
|
currentTime?: number;
|
|
696
768
|
duration?: number;
|
|
697
769
|
paused?: boolean;
|
|
770
|
+
ended?: boolean;
|
|
698
771
|
addEventListener?: (type: string, listener: () => void) => void;
|
|
699
772
|
removeEventListener?: (type: string, listener: () => void) => void;
|
|
700
773
|
} | null>(null);
|
|
@@ -1100,9 +1173,93 @@ function FilePlayer({
|
|
|
1100
1173
|
);
|
|
1101
1174
|
}
|
|
1102
1175
|
|
|
1176
|
+
// Only chrome-bearing surfaces (a user actively watching) monitor health —
|
|
1177
|
+
// decorative first-frame and hover previews pass `chromeless` and stay silent.
|
|
1178
|
+
const monitorHealth = !chromeless;
|
|
1179
|
+
const [playbackFailed, setPlaybackFailed] = useState(false);
|
|
1180
|
+
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1181
|
+
const clearStallTimer = useCallback(() => {
|
|
1182
|
+
if (stallTimerRef.current !== null) {
|
|
1183
|
+
clearTimeout(stallTimerRef.current);
|
|
1184
|
+
stallTimerRef.current = null;
|
|
1185
|
+
}
|
|
1186
|
+
}, []);
|
|
1187
|
+
useEffect(() => clearStallTimer, [clearStallTimer]);
|
|
1188
|
+
|
|
1189
|
+
const failPlayback = useCallback(
|
|
1190
|
+
(reason: VideoFailureReason) => {
|
|
1191
|
+
clearStallTimer();
|
|
1192
|
+
setPlaybackFailed(true);
|
|
1193
|
+
reportPlaybackFailure({ kind: 'file', src: url, reason });
|
|
1194
|
+
},
|
|
1195
|
+
[clearStallTimer, url],
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
// A manually paused or finished video is never a stall.
|
|
1199
|
+
const armStallTimer = useCallback(() => {
|
|
1200
|
+
if (!monitorHealth) return;
|
|
1201
|
+
const el = hoverPlayerRef.current;
|
|
1202
|
+
if (!el || el.paused || el.ended) return;
|
|
1203
|
+
if (stallTimerRef.current !== null) return;
|
|
1204
|
+
stallTimerRef.current = setTimeout(() => {
|
|
1205
|
+
stallTimerRef.current = null;
|
|
1206
|
+
failPlayback('stall');
|
|
1207
|
+
}, VIDEO_STALL_TIMEOUT_MS);
|
|
1208
|
+
}, [monitorHealth, failPlayback]);
|
|
1209
|
+
|
|
1210
|
+
// Progress means the player is alive — a stall that self-heals clears its own
|
|
1211
|
+
// overlay.
|
|
1212
|
+
const handlePlaybackProgress = useCallback(() => {
|
|
1213
|
+
clearStallTimer();
|
|
1214
|
+
setPlaybackFailed(false);
|
|
1215
|
+
}, [clearStallTimer]);
|
|
1216
|
+
|
|
1217
|
+
const handleMediaError = useCallback(() => {
|
|
1218
|
+
if (!monitorHealth) return;
|
|
1219
|
+
failPlayback('error');
|
|
1220
|
+
}, [monitorHealth, failPlayback]);
|
|
1221
|
+
|
|
1222
|
+
// `load()` re-inits the HLS pipeline (`play()` alone can't) but rewinds to
|
|
1223
|
+
// zero — stash the position and seek back on `loadedmetadata`, since an
|
|
1224
|
+
// earlier assignment is dropped while the fresh source has no seekable range.
|
|
1225
|
+
const retryPlayback = useCallback(() => {
|
|
1226
|
+
setPlaybackFailed(false);
|
|
1227
|
+
clearStallTimer();
|
|
1228
|
+
const el = hoverPlayerRef.current;
|
|
1229
|
+
if (!el) return;
|
|
1230
|
+
const resumeAt = typeof el.currentTime === 'number' ? el.currentTime : 0;
|
|
1231
|
+
try {
|
|
1232
|
+
el.load?.();
|
|
1233
|
+
if (resumeAt > 0 && el.addEventListener && el.removeEventListener) {
|
|
1234
|
+
const seekBack = () => {
|
|
1235
|
+
el.removeEventListener?.('loadedmetadata', seekBack);
|
|
1236
|
+
try {
|
|
1237
|
+
el.currentTime = resumeAt;
|
|
1238
|
+
} catch {
|
|
1239
|
+
/* source shorter than the stashed position — start from the top */
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
el.addEventListener('loadedmetadata', seekBack);
|
|
1243
|
+
}
|
|
1244
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.(() => {
|
|
1245
|
+
/* a re-rejected play surfaces again via the error/stall handlers */
|
|
1246
|
+
});
|
|
1247
|
+
} catch {
|
|
1248
|
+
/* element torn down */
|
|
1249
|
+
}
|
|
1250
|
+
}, [clearStallTimer]);
|
|
1251
|
+
|
|
1252
|
+
const errorOverlay = monitorHealth && playbackFailed ? <VideoErrorOverlay onRetry={retryPlayback} /> : null;
|
|
1253
|
+
|
|
1103
1254
|
const player = (
|
|
1104
1255
|
<MuxPlayer
|
|
1105
1256
|
ref={hoverPlayerRef as React.Ref<never>}
|
|
1257
|
+
onError={handleMediaError}
|
|
1258
|
+
onWaiting={armStallTimer}
|
|
1259
|
+
onStalled={armStallTimer}
|
|
1260
|
+
onPlaying={handlePlaybackProgress}
|
|
1261
|
+
onTimeUpdate={handlePlaybackProgress}
|
|
1262
|
+
onPause={clearStallTimer}
|
|
1106
1263
|
src={url}
|
|
1107
1264
|
poster={poster || undefined}
|
|
1108
1265
|
streamType="on-demand"
|
|
@@ -1187,26 +1344,18 @@ function FilePlayer({
|
|
|
1187
1344
|
<div className="relative h-full w-full" onPointerEnter={handleHoverEnter} onPointerLeave={handleHoverLeave}>
|
|
1188
1345
|
{player}
|
|
1189
1346
|
{unmuteBadge}
|
|
1347
|
+
{errorOverlay}
|
|
1190
1348
|
</div>
|
|
1191
1349
|
);
|
|
1192
1350
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
{player}
|
|
1197
|
-
{unmuteBadge}
|
|
1198
|
-
</div>
|
|
1199
|
-
);
|
|
1200
|
-
}
|
|
1201
|
-
// Handoff surfaces (autoPlayUnmuted / startMuted) need the relative wrapper so
|
|
1202
|
-
// the internal center unmute badge can dock — the bare branch has none. Hosts
|
|
1203
|
-
// that render their OWN control pass hideMutedBadge (unmuteBadge is null then,
|
|
1204
|
-
// but the wrapper is harmless).
|
|
1205
|
-
if (autoPlayUnmuted || startMuted) {
|
|
1351
|
+
// A relative wrapper so the unmute badge and the error overlay can dock. The
|
|
1352
|
+
// bare `player` return is left only for chromeless previews (never monitored).
|
|
1353
|
+
if (hoverControlled || autoPlayUnmuted || startMuted || monitorHealth) {
|
|
1206
1354
|
return (
|
|
1207
1355
|
<div className="relative h-full w-full">
|
|
1208
1356
|
{player}
|
|
1209
1357
|
{unmuteBadge}
|
|
1358
|
+
{errorOverlay}
|
|
1210
1359
|
</div>
|
|
1211
1360
|
);
|
|
1212
1361
|
}
|
|
@@ -1288,6 +1437,9 @@ const YT_PLAYING_BLUR_DELAY_MS = 1000;
|
|
|
1288
1437
|
interface YouTubeInfoDeliveryMessage {
|
|
1289
1438
|
event?: string;
|
|
1290
1439
|
info?: { playerState?: number };
|
|
1440
|
+
/** `onError` carries its code as a bare number in `info`, not an object:
|
|
1441
|
+
* `{"event":"onError","info":150}` (101/150 = embedding disabled). */
|
|
1442
|
+
errorCode?: number;
|
|
1291
1443
|
}
|
|
1292
1444
|
|
|
1293
1445
|
/**
|
|
@@ -1307,6 +1459,7 @@ function toYouTubeMessage(parsed: unknown): YouTubeInfoDeliveryMessage | null {
|
|
|
1307
1459
|
return {
|
|
1308
1460
|
event: typeof event === 'string' ? event : undefined,
|
|
1309
1461
|
info: { playerState: typeof playerState === 'number' ? playerState : undefined },
|
|
1462
|
+
errorCode: typeof info === 'number' ? info : undefined,
|
|
1310
1463
|
};
|
|
1311
1464
|
}
|
|
1312
1465
|
|
|
@@ -1320,8 +1473,18 @@ function YouTubeFacadeInner({
|
|
|
1320
1473
|
suspended,
|
|
1321
1474
|
}: YouTubeFacadeInnerProps): React.ReactElement {
|
|
1322
1475
|
const [activated, setActivated] = useState(Boolean(autoActivate));
|
|
1476
|
+
// The embed reported a hard player error (see the `onError` branch below).
|
|
1477
|
+
// Retry remounts the iframe via `reloadNonce` — a fresh element, since there
|
|
1478
|
+
// is no way to re-init a cross-origin player from outside.
|
|
1479
|
+
const [failed, setFailed] = useState(false);
|
|
1480
|
+
const [reloadNonce, setReloadNonce] = useState(0);
|
|
1323
1481
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
1324
1482
|
|
|
1483
|
+
const retry = useCallback(() => {
|
|
1484
|
+
setFailed(false);
|
|
1485
|
+
setReloadNonce(n => n + 1);
|
|
1486
|
+
}, []);
|
|
1487
|
+
|
|
1325
1488
|
// Embed URL + poster URLs only change when `videoId` or `minimalControls`
|
|
1326
1489
|
// do — memoize so we don't rebuild URLSearchParams on every render.
|
|
1327
1490
|
//
|
|
@@ -1414,7 +1577,7 @@ function YouTubeFacadeInner({
|
|
|
1414
1577
|
// end-of-video (ENDED).
|
|
1415
1578
|
// ---------------------------------------------------------------------------
|
|
1416
1579
|
useEffect(() => {
|
|
1417
|
-
if (!activated) return undefined;
|
|
1580
|
+
if (!activated || failed) return undefined;
|
|
1418
1581
|
const iframe = iframeRef.current;
|
|
1419
1582
|
if (!iframe) return undefined;
|
|
1420
1583
|
|
|
@@ -1427,6 +1590,10 @@ function YouTubeFacadeInner({
|
|
|
1427
1590
|
|
|
1428
1591
|
let blurTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1429
1592
|
|
|
1593
|
+
// No "never reached PLAYING within N seconds" watchdog on purpose: an embed
|
|
1594
|
+
// whose unmuted autoplay Safari/iOS blocked, or one whose jsapi channel is
|
|
1595
|
+
// dead, is healthy and silent — a timeout would tear those down, and retry
|
|
1596
|
+
// would re-arm it. Only an explicit `onError` is a definitive failure.
|
|
1430
1597
|
function handleMessage(event: MessageEvent) {
|
|
1431
1598
|
if (event.origin !== YT_NOCOOKIE_ORIGIN) return;
|
|
1432
1599
|
if (typeof event.data !== 'string') return;
|
|
@@ -1436,7 +1603,13 @@ function YouTubeFacadeInner({
|
|
|
1436
1603
|
} catch {
|
|
1437
1604
|
return;
|
|
1438
1605
|
}
|
|
1439
|
-
if (!payload
|
|
1606
|
+
if (!payload) return;
|
|
1607
|
+
if (payload.event === 'onError') {
|
|
1608
|
+
setFailed(true);
|
|
1609
|
+
reportPlaybackFailure({ kind: 'youtube', src: videoId, reason: 'error', errorCode: payload.errorCode });
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (payload.event !== 'infoDelivery') return;
|
|
1440
1613
|
const state = payload.info?.playerState;
|
|
1441
1614
|
if (typeof state !== 'number') return;
|
|
1442
1615
|
|
|
@@ -1459,7 +1632,7 @@ function YouTubeFacadeInner({
|
|
|
1459
1632
|
window.removeEventListener('message', handleMessage);
|
|
1460
1633
|
if (blurTimer !== null) clearTimeout(blurTimer);
|
|
1461
1634
|
};
|
|
1462
|
-
}, [activated]);
|
|
1635
|
+
}, [activated, failed, reloadNonce, videoId]);
|
|
1463
1636
|
|
|
1464
1637
|
// Close-side pause: a closing dialog flips `suspended` false→true. Post the
|
|
1465
1638
|
// pauseVideo command over the same enablejsapi channel so the iframe stops
|
|
@@ -1482,10 +1655,19 @@ function YouTubeFacadeInner({
|
|
|
1482
1655
|
const wrapperClass = `relative w-full ${className ?? ''}`;
|
|
1483
1656
|
const wrapperStyle = { paddingBottom: '56.25%' as const };
|
|
1484
1657
|
|
|
1658
|
+
if (activated && failed) {
|
|
1659
|
+
return (
|
|
1660
|
+
<div className={wrapperClass} style={wrapperStyle}>
|
|
1661
|
+
<VideoErrorOverlay onRetry={retry} watchOnYouTubeId={videoId} />
|
|
1662
|
+
</div>
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1485
1666
|
if (activated) {
|
|
1486
1667
|
return (
|
|
1487
1668
|
<div className={wrapperClass} style={wrapperStyle}>
|
|
1488
1669
|
<iframe
|
|
1670
|
+
key={reloadNonce}
|
|
1489
1671
|
ref={iframeRef}
|
|
1490
1672
|
src={embedUrl}
|
|
1491
1673
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|