@video-lab/react-frame 3.1.0 → 4.0.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/index.cjs CHANGED
@@ -1,8 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _video_lab_react_config = require("@video-lab/react-config");
3
+ let _video_lab_protocol = require("@video-lab/protocol");
2
4
  let react = require("react");
3
5
  let _video_lab_frame_core = require("@video-lab/frame-core");
4
6
  let react_jsx_runtime = require("react/jsx-runtime");
5
7
  //#region src/use-frame-connection.ts
8
+ let hostProducerSequence = 0;
6
9
  /**
7
10
  * 管理 frame-core 连接的整个生命周期,并把 iframe 事件桥接到 React 回调 props。
8
11
  *
@@ -13,33 +16,324 @@ let react_jsx_runtime = require("react/jsx-runtime");
13
16
  *
14
17
  * SSR 安全:`createPlayerFrame` 只在 useEffect(客户端)里跑,iframe 创建天然被挡在 SSR 之外。
15
18
  */
16
- function useFrameConnection(props) {
19
+ function useFrameConnection(props, hasCustomPoster = false, hasCustomPause = false) {
17
20
  const containerRef = (0, react.useRef)(null);
18
21
  const handleRef = (0, react.useRef)(null);
19
22
  const [mounted, setMounted] = (0, react.useState)(false);
23
+ const [playIntent, setPlayIntent] = (0, react.useState)((props.autoplay ?? (props.preset ? _video_lab_protocol.PRESETS[props.preset].autoplay : false)) === true || props.preload === "auto");
24
+ const [rebuildKey, setRebuildKey] = (0, react.useState)(0);
25
+ const [sourceOverride, setSourceOverride] = (0, react.useState)(null);
26
+ const [sourceSwitching, setSourceSwitching] = (0, react.useState)(false);
27
+ const [hostOwnsOverlays, setHostOwnsOverlays] = (0, react.useState)(false);
28
+ const [terminalError, setTerminalError] = (0, react.useState)(null);
29
+ const terminalRef = (0, react.useRef)(null);
30
+ const diagnosticRef = (0, react.useRef)(null);
31
+ const actionBusyRef = (0, react.useRef)(false);
20
32
  const [hostPosterVisible, setHostPosterVisible] = (0, react.useState)(true);
33
+ const [hostPauseVisible, setHostPauseVisible] = (0, react.useState)(false);
34
+ const pauseIntentPendingRef = (0, react.useRef)(false);
35
+ const firstFrameSeenRef = (0, react.useRef)(false);
36
+ const awaitingNewSourceRef = (0, react.useRef)(false);
37
+ const [loadingOverlay, setLoadingOverlay] = (0, react.useState)({
38
+ phase: "initial",
39
+ reason: "frame_disconnected"
40
+ });
21
41
  const propsRef = (0, react.useRef)(props);
22
42
  propsRef.current = props;
43
+ const activeSourceRef = (0, react.useRef)(props.source);
44
+ activeSourceRef.current = sourceOverride ?? props.source;
45
+ const sourceSwitchRef = (0, react.useRef)(null);
46
+ const sourceSwitchSequence = (0, react.useRef)(0);
47
+ const promptSourceGeneration = (0, react.useRef)(0);
48
+ const playingIntentRef = (0, react.useRef)((props.autoplay ?? (props.preset ? _video_lab_protocol.PRESETS[props.preset].autoplay : false)) === true);
49
+ const hostDeliveryRef = (0, react.useRef)(null);
50
+ const forwardWithDelivery = (0, react.useCallback)((event, currentProps) => {
51
+ if (event.delivery) {
52
+ forward(event, currentProps);
53
+ return;
54
+ }
55
+ hostDeliveryRef.current ??= {
56
+ sessionId: `react-frame-${Date.now()}-${++hostProducerSequence}`,
57
+ sequence: 0
58
+ };
59
+ const producer = hostDeliveryRef.current;
60
+ producer.sequence += 1;
61
+ forward({
62
+ ...event,
63
+ delivery: {
64
+ producerSessionId: producer.sessionId,
65
+ deliveryId: `${producer.sessionId}:${producer.sequence}`,
66
+ occurredAtMs: Date.now(),
67
+ sequence: producer.sequence
68
+ }
69
+ }, currentProps);
70
+ }, []);
71
+ const cancelSourceSwitch = (reason = "unmounted") => {
72
+ const transaction = sourceSwitchRef.current;
73
+ transaction?.controller.abort();
74
+ sourceSwitchRef.current = null;
75
+ setSourceSwitching(false);
76
+ if (!transaction) return;
77
+ handleRef.current?.setSourceSwitching(false).catch(() => {});
78
+ forwardWithDelivery({
79
+ event: "sourceswitch",
80
+ payload: {
81
+ phase: "cancelled",
82
+ switchId: transaction.id,
83
+ reason,
84
+ elapsedMs: sourceSwitchElapsed(transaction.startedAt)
85
+ }
86
+ }, propsRef.current);
87
+ };
88
+ const endSourceSwitchAsError = (id, reason, errorCode) => {
89
+ const transaction = sourceSwitchRef.current;
90
+ if (transaction?.id !== id) return;
91
+ sourceSwitchRef.current = null;
92
+ setSourceSwitching(false);
93
+ awaitingNewSourceRef.current = false;
94
+ handleRef.current?.setSourceSwitching(false).catch(() => {});
95
+ forwardWithDelivery({
96
+ event: "sourceswitch",
97
+ payload: {
98
+ phase: "failed",
99
+ switchId: transaction.id,
100
+ reason,
101
+ elapsedMs: sourceSwitchElapsed(transaction.startedAt),
102
+ ...errorCode === void 0 ? {} : { errorCode }
103
+ }
104
+ }, propsRef.current);
105
+ forwardWithDelivery({
106
+ event: "playablechange",
107
+ payload: {
108
+ playable: false,
109
+ recoverable: false,
110
+ reason: "error",
111
+ action: "replace-source"
112
+ }
113
+ }, propsRef.current);
114
+ const terminal = {
115
+ error: diagnosticRef.current ?? (0, _video_lab_protocol.makePlayerError)("E_AUTH_EXPIRED", ""),
116
+ action: "replace-source",
117
+ reason: "error",
118
+ synthetic: true
119
+ };
120
+ terminalRef.current = terminal;
121
+ setTerminalError(terminal);
122
+ };
123
+ const restoreSourceSwitchOnReady = () => {
124
+ const transaction = sourceSwitchRef.current;
125
+ const current = handleRef.current;
126
+ if (!transaction?.loadAccepted || !current) return;
127
+ sourceSwitchRef.current = null;
128
+ setSourceSwitching(false);
129
+ handleRef.current?.setSourceSwitching(false).catch(() => {});
130
+ forwardWithDelivery({
131
+ event: "sourceswitch",
132
+ payload: {
133
+ phase: "succeeded",
134
+ switchId: transaction.id,
135
+ strategy: transaction.strategy,
136
+ elapsedMs: sourceSwitchElapsed(transaction.startedAt)
137
+ }
138
+ }, propsRef.current);
139
+ if (transaction.restorePosition !== null) current.seek(transaction.restorePosition).catch(() => {});
140
+ if (transaction.wasPlaying) current.play().catch(() => {});
141
+ };
142
+ const startSourceSwitch = (cause) => {
143
+ const resolveSource = propsRef.current.resolveSource;
144
+ if (cause.code !== "E_AUTH_EXPIRED" || !resolveSource || sourceSwitchRef.current) return false;
145
+ const id = ++sourceSwitchSequence.current;
146
+ const controller = new AbortController();
147
+ const context = handleRef.current?.getPlaybackContext();
148
+ sourceSwitchRef.current = {
149
+ controller,
150
+ id,
151
+ restorePosition: context?.streamKind === "vod" && Number.isFinite(context.position) ? context.position : null,
152
+ wasPlaying: playingIntentRef.current,
153
+ loadAccepted: false,
154
+ startedAt: sourceSwitchNow(),
155
+ strategy: "hot_load"
156
+ };
157
+ const httpStatus = cause.cause?.status;
158
+ forwardWithDelivery({
159
+ event: "sourceswitch",
160
+ payload: {
161
+ phase: "started",
162
+ switchId: id,
163
+ trigger: "auth_expired",
164
+ ...httpStatus === 401 || httpStatus === 403 ? { httpStatus } : {}
165
+ }
166
+ }, propsRef.current);
167
+ setSourceSwitching(true);
168
+ pauseIntentPendingRef.current = false;
169
+ setHostPauseVisible(false);
170
+ awaitingNewSourceRef.current = "switching";
171
+ setLoadingOverlay({
172
+ phase: firstFrameSeenRef.current ? "runtime" : "initial",
173
+ reason: "source_switching"
174
+ });
175
+ handleRef.current?.setSourceSwitching(true).catch(() => {});
176
+ forwardWithDelivery({
177
+ event: "playablechange",
178
+ payload: {
179
+ playable: false,
180
+ recoverable: true,
181
+ reason: "source_switching",
182
+ action: "none"
183
+ }
184
+ }, propsRef.current);
185
+ Promise.resolve().then(() => resolveSource({
186
+ cause,
187
+ signal: controller.signal
188
+ })).then((source) => {
189
+ if (sourceSwitchRef.current?.id !== id || controller.signal.aborted) return;
190
+ if (source === null || !handleRef.current) return endSourceSwitchAsError(id, "resolver_null");
191
+ sourceSwitchRef.current.loadAccepted = true;
192
+ handleRef.current.load(source).then(() => {}, (error) => {
193
+ const playerError = (0, _video_lab_frame_core.toPlayerError)(error);
194
+ if (playerError.code !== "E_METHOD_NOT_SUPPORTED") return endSourceSwitchAsError(id, "load_rejected", playerError.code);
195
+ if (sourceSwitchRef.current?.id !== id || controller.signal.aborted) return;
196
+ sourceSwitchRef.current.strategy = "rebuild";
197
+ setSourceOverride(source);
198
+ setRebuildKey((key) => key + 1);
199
+ });
200
+ }).catch(() => {
201
+ if (!controller.signal.aborted) endSourceSwitchAsError(id, "resolver_rejected");
202
+ });
203
+ return true;
204
+ };
205
+ const performErrorAction = async () => {
206
+ if (actionBusyRef.current || sourceSwitchRef.current) return "busy";
207
+ const current = handleRef.current;
208
+ const terminal = terminalRef.current;
209
+ if (!current || !terminal) return "unavailable";
210
+ const action = (0, _video_lab_protocol.effectiveErrorAction)({
211
+ reason: terminal.reason,
212
+ recoverable: false,
213
+ action: terminal.action,
214
+ errorCode: terminal.error.code,
215
+ canResolveSource: Boolean(propsRef.current.resolveSource),
216
+ canRecreateFrame: true
217
+ });
218
+ if (action === "none") return "unavailable";
219
+ if (action === "replace-source") return startSourceSwitch(terminal.error) ? "started" : "unavailable";
220
+ actionBusyRef.current = true;
221
+ try {
222
+ if (action === "recreate-frame") {
223
+ await current.destroy();
224
+ handleRef.current = null;
225
+ terminalRef.current = null;
226
+ setTerminalError(null);
227
+ diagnosticRef.current = null;
228
+ setHostOwnsOverlays(false);
229
+ firstFrameSeenRef.current = false;
230
+ awaitingNewSourceRef.current = false;
231
+ setHostPosterVisible(true);
232
+ setHostPauseVisible(false);
233
+ setLoadingOverlay({
234
+ phase: "initial",
235
+ reason: "frame_disconnected"
236
+ });
237
+ setRebuildKey((key) => key + 1);
238
+ } else if (action === "play") await current.play();
239
+ else await current.retry();
240
+ return "started";
241
+ } finally {
242
+ actionBusyRef.current = false;
243
+ }
244
+ };
23
245
  (0, react.useEffect)(() => {
24
246
  let cancelled = false;
25
247
  const container = containerRef.current;
26
248
  if (!container) return;
27
249
  const p = propsRef.current;
250
+ setHostOwnsOverlays(false);
28
251
  (0, _video_lab_frame_core.createPlayerFrame)({
29
252
  container,
30
- source: p.source,
31
- config: toConfig(p),
253
+ source: activeSourceRef.current,
254
+ config: toConfig(p, hasCustomPoster, hasCustomPause),
255
+ renderDefaultFallback: false,
256
+ onHostOverlayOwnershipChange: (owned) => setHostOwnsOverlays(owned),
257
+ sourceSwitching: sourceSwitchRef.current !== null,
32
258
  pageFullscreen: p.pageFullscreen,
33
259
  ...p.origin !== void 0 ? { origin: p.origin } : {},
34
260
  ...p.iframeVersion !== void 0 ? { iframeVersion: p.iframeVersion } : {},
35
261
  onEvent: (event) => {
36
- if (event.event === "play" || event.event === "error") setHostPosterVisible(false);
37
- forward(event, propsRef.current);
38
- },
39
- onDeliveredEvent: (event) => {
40
- try {
41
- propsRef.current.onDeliveredPlayerEvent?.(event);
42
- } catch {}
262
+ if (cancelled) return;
263
+ if (event.event === "useraction" && event.payload.action === "switch_play_pause") {
264
+ if (event.payload.to === false) pauseIntentPendingRef.current = false;
265
+ else if (event.payload.to === true || event.payload.to === null && playingIntentRef.current) pauseIntentPendingRef.current = true;
266
+ }
267
+ if (event.event === "contextchange") {
268
+ pauseIntentPendingRef.current = false;
269
+ setHostPauseVisible(false);
270
+ }
271
+ if (event.event === "pause") {
272
+ setHostPauseVisible(pauseIntentPendingRef.current);
273
+ pauseIntentPendingRef.current = false;
274
+ }
275
+ if (event.event === "play" || event.event === "ended") {
276
+ pauseIntentPendingRef.current = false;
277
+ setHostPauseVisible(false);
278
+ }
279
+ if (event.event === "play" || event.event === "playing") {
280
+ playingIntentRef.current = true;
281
+ setPlayIntent(true);
282
+ }
283
+ if (event.event === "pause" || event.event === "ended") {
284
+ playingIntentRef.current = false;
285
+ setPlayIntent(false);
286
+ }
287
+ if (event.event === "error") {
288
+ diagnosticRef.current = event.payload;
289
+ const active = sourceSwitchRef.current;
290
+ if (active?.loadAccepted) endSourceSwitchAsError(active.id, "replacement_error", event.payload.code);
291
+ startSourceSwitch(event.payload);
292
+ }
293
+ if (event.event === "ready") {
294
+ if (awaitingNewSourceRef.current === "switching") awaitingNewSourceRef.current = "new-source";
295
+ restoreSourceSwitchOnReady();
296
+ }
297
+ if (event.event === "playablechange" && event.payload.reason === "error" && sourceSwitchRef.current) return;
298
+ if (event.event === "playablechange") {
299
+ const { playable, recoverable, reason, action = "none" } = event.payload;
300
+ if (!playable && !recoverable && (reason === "error" || reason === "frame_disconnected" || reason === "autoplay_blocked")) {
301
+ const terminal = {
302
+ error: reason === "autoplay_blocked" ? (0, _video_lab_protocol.makePlayerError)("E_AUTOPLAY_BLOCKED", "") : diagnosticRef.current ?? (0, _video_lab_protocol.makePlayerError)("E_UNKNOWN", ""),
303
+ action,
304
+ reason
305
+ };
306
+ terminalRef.current = terminal;
307
+ setTerminalError(terminal);
308
+ pauseIntentPendingRef.current = false;
309
+ setHostPauseVisible(false);
310
+ } else if (playable || recoverable) {
311
+ terminalRef.current = null;
312
+ setTerminalError(null);
313
+ if (playable) diagnosticRef.current = null;
314
+ }
315
+ }
316
+ if (event.event === "firstframe" || event.event === "timeupdate") if (awaitingNewSourceRef.current === "switching") {} else {
317
+ const newSourceArrived = awaitingNewSourceRef.current === "new-source";
318
+ if (newSourceArrived) awaitingNewSourceRef.current = false;
319
+ firstFrameSeenRef.current = true;
320
+ setHostPosterVisible(false);
321
+ setLoadingOverlay((prev) => newSourceArrived || prev?.phase !== "runtime" ? null : prev);
322
+ }
323
+ else if (event.event === "playablechange") {
324
+ const { playable, recoverable, reason, action = "none" } = event.payload;
325
+ const terminalError = !playable && !recoverable && reason === "error";
326
+ const blockingAction = !playable && !recoverable && action !== "none";
327
+ if (terminalError) setHostPosterVisible(false);
328
+ if (!(awaitingNewSourceRef.current !== false && (reason === "initializing" || reason === "ok"))) setLoadingOverlay(!firstFrameSeenRef.current ? terminalError || blockingAction ? null : {
329
+ phase: "initial",
330
+ reason
331
+ } : !playable && recoverable && reason !== "initializing" ? {
332
+ phase: "runtime",
333
+ reason
334
+ } : null);
335
+ }
336
+ forwardWithDelivery(event, propsRef.current);
43
337
  },
44
338
  debug: p.debug ?? false
45
339
  }).then((h) => {
@@ -49,47 +343,124 @@ function useFrameConnection(props) {
49
343
  }
50
344
  handleRef.current = h;
51
345
  setMounted(true);
346
+ if (propsRef.current.showErrorOverlay !== p.showErrorOverlay || propsRef.current.errorButtonColors !== p.errorButtonColors) h.setOverlayPresentation({
347
+ showErrorOverlay: propsRef.current.showErrorOverlay !== false,
348
+ ...propsRef.current.errorButtonColors ? { errorButtonColors: propsRef.current.errorButtonColors } : {}
349
+ }).catch(() => {});
350
+ const latestLocale = propsRef.current.locale;
351
+ if (latestLocale !== void 0 && JSON.stringify(latestLocale) !== JSON.stringify(p.locale)) h.setLocale(latestLocale).catch((err) => forwardWithDelivery({
352
+ event: "error",
353
+ payload: (0, _video_lab_frame_core.toPlayerError)(err)
354
+ }, propsRef.current));
52
355
  });
53
356
  return () => {
54
357
  cancelled = true;
55
358
  handleRef.current?.destroy();
56
359
  handleRef.current = null;
57
360
  };
58
- }, []);
361
+ }, [rebuildKey]);
362
+ (0, react.useEffect)(() => cancelSourceSwitch, []);
59
363
  const sourceKey = JSON.stringify(props.source);
60
364
  (0, react.useEffect)(() => {
61
- handleRef.current?.load(props.source).catch((err) => forward({
62
- event: "error",
63
- payload: (0, _video_lab_frame_core.toPlayerError)(err)
64
- }, propsRef.current));
365
+ cancelSourceSwitch("external_source");
366
+ setSourceOverride(null);
367
+ const generation = ++promptSourceGeneration.current;
368
+ handleRef.current?.load(props.source).catch((err) => {
369
+ const playerError = (0, _video_lab_frame_core.toPlayerError)(err);
370
+ if (promptSourceGeneration.current !== generation) {
371
+ forwardWithDelivery({
372
+ event: "error",
373
+ payload: playerError
374
+ }, propsRef.current);
375
+ return;
376
+ }
377
+ diagnosticRef.current = playerError;
378
+ const terminal = {
379
+ error: playerError,
380
+ action: "replace-source",
381
+ reason: "error",
382
+ synthetic: true
383
+ };
384
+ terminalRef.current = terminal;
385
+ setTerminalError(terminal);
386
+ awaitingNewSourceRef.current = false;
387
+ setHostPosterVisible(false);
388
+ setHostPauseVisible(false);
389
+ setLoadingOverlay(null);
390
+ forwardWithDelivery({
391
+ event: "error",
392
+ payload: playerError
393
+ }, propsRef.current);
394
+ });
65
395
  }, [sourceKey]);
66
396
  (0, react.useEffect)(() => {
67
- if (props.muted !== void 0) handleRef.current?.setMuted(props.muted).catch((err) => forward({
397
+ if (props.muted !== void 0) handleRef.current?.setMuted(props.muted).catch((err) => forwardWithDelivery({
68
398
  event: "error",
69
399
  payload: (0, _video_lab_frame_core.toPlayerError)(err)
70
400
  }, propsRef.current));
71
- }, [props.muted]);
401
+ }, [props.muted, forwardWithDelivery]);
72
402
  (0, react.useEffect)(() => {
73
- if (props.locale !== void 0) handleRef.current?.setLocale(props.locale).catch((err) => forward({
403
+ if (props.locale !== void 0) handleRef.current?.setLocale(props.locale).catch((err) => forwardWithDelivery({
74
404
  event: "error",
75
405
  payload: (0, _video_lab_frame_core.toPlayerError)(err)
76
406
  }, propsRef.current));
77
- }, [props.locale]);
407
+ }, [props.locale, forwardWithDelivery]);
408
+ (0, react.useEffect)(() => {
409
+ if (!hostOwnsOverlays) return;
410
+ handleRef.current?.setOverlayPresentation({
411
+ showErrorOverlay: props.showErrorOverlay !== false,
412
+ ...props.errorButtonColors ? { errorButtonColors: props.errorButtonColors } : {}
413
+ }).catch(() => {});
414
+ }, [
415
+ hostOwnsOverlays,
416
+ props.showErrorOverlay,
417
+ props.errorButtonColors
418
+ ]);
419
+ const previousSourceKeyRef = (0, react.useRef)(sourceKey);
78
420
  (0, react.useEffect)(() => {
421
+ if (previousSourceKeyRef.current === sourceKey) return;
422
+ previousSourceKeyRef.current = sourceKey;
79
423
  setHostPosterVisible(true);
424
+ pauseIntentPendingRef.current = false;
425
+ setHostPauseVisible(false);
426
+ firstFrameSeenRef.current = false;
427
+ awaitingNewSourceRef.current = "switching";
428
+ setLoadingOverlay({
429
+ phase: "initial",
430
+ reason: "initializing"
431
+ });
80
432
  }, [sourceKey]);
81
433
  return {
82
434
  containerRef,
83
435
  handleRef,
84
436
  mounted,
85
- hostPosterVisible
437
+ hostPosterVisible,
438
+ hostPauseVisible,
439
+ pause: (0, react.useCallback)(async () => {
440
+ const current = handleRef.current;
441
+ if (!current) return;
442
+ pauseIntentPendingRef.current = true;
443
+ try {
444
+ await current.pause();
445
+ } catch (error) {
446
+ pauseIntentPendingRef.current = false;
447
+ throw error;
448
+ }
449
+ }, []),
450
+ playIntent,
451
+ loadingOverlay,
452
+ terminalError,
453
+ hostOwnsOverlays,
454
+ performErrorAction,
455
+ sourceSwitching,
456
+ cancelSourceSwitch
86
457
  };
87
458
  }
88
459
  /**
89
460
  * 只把"显式传了的"字段带上;undefined 交给 iframe 侧走 preset / xgplayer 默认。
90
461
  * 和 vue-frame 的 toConfig 语义一致(resolvePreset 的语义就是 undefined = 没传)。
91
462
  */
92
- function toConfig(props) {
463
+ function toConfig(props, hasCustomPoster = false, hasCustomPause = false) {
93
464
  const config = {};
94
465
  if (props.preset !== void 0) config.preset = props.preset;
95
466
  if (props.autoplay !== void 0) config.autoplay = props.autoplay;
@@ -102,23 +473,32 @@ function toConfig(props) {
102
473
  if (props.preload !== void 0) config.preload = props.preload;
103
474
  if (props.controls !== void 0) config.controls = props.controls;
104
475
  if (props.showErrorOverlay !== void 0) config.showErrorOverlay = props.showErrorOverlay;
105
- if (props.showLoadingOverlay !== void 0) config.showLoadingOverlay = props.showLoadingOverlay;
476
+ if (props.renderLoading !== void 0) config.showDefaultLoadingOverlay = false;
477
+ else if (props.showDefaultLoadingOverlay !== void 0) config.showDefaultLoadingOverlay = props.showDefaultLoadingOverlay;
478
+ if (props.showLoadingText !== void 0) config.showLoadingText = props.showLoadingText;
479
+ if (props.errorButtonColors !== void 0) config.errorButtonColors = props.errorButtonColors;
106
480
  if (props.controlVisibility !== void 0) config.controlVisibility = props.controlVisibility;
107
481
  if (props.interactive !== void 0) config.interactive = props.interactive;
108
- if (props.poster !== void 0) config.poster = props.poster;
109
- if (props.pauseImage !== void 0) config.pauseImage = props.pauseImage;
482
+ if (!hasCustomPoster && props.poster != null) config.poster = props.poster;
483
+ if (!hasCustomPause && props.pauseImage != null) config.pauseImage = props.pauseImage;
110
484
  if (props.locale !== void 0) config.locale = props.locale;
111
485
  if (props.danmaku !== void 0) config.danmaku = props.danmaku;
112
486
  if (props.debug !== void 0) config.debug = props.debug;
113
487
  return config;
114
488
  }
489
+ function sourceSwitchNow() {
490
+ return globalThis.performance?.now?.() ?? Date.now();
491
+ }
492
+ function sourceSwitchElapsed(startedAt) {
493
+ return Math.max(0, sourceSwitchNow() - startedAt);
494
+ }
115
495
  /**
116
496
  * iframe 事件 → React 回调。名字从 wire 上的小写拼写换成 `onCamelCase`。
117
497
  * 和 vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload 裁剪。
118
498
  *
119
- * **契约声明的 27 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
499
+ * **契约声明的 28 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
120
500
  * 静态断言。曾经漏过 seeking / seeked / waiting / playing 四个,理由是"团队封装层没用到" ——
121
- * 但静态 iframe 模式是裸广播、27 个全发,于是同一份契约在四种接入方式下能收到的事件
501
+ * 但静态 iframe 模式是裸广播、28 个全发,于是同一份契约在四种接入方式下能收到的事件
122
502
  * 不一样,parity 红线实际已破。别再以"业务暂时用不到"为由少接一个。
123
503
  */
124
504
  function forward(event, props) {
@@ -204,7 +584,8 @@ function forward(event, props) {
204
584
  case "audiohealth":
205
585
  props.onAudioHealth?.(event.payload);
206
586
  break;
207
- case "recovery": break;
587
+ case "recovery":
588
+ case "sourceswitch": break;
208
589
  default: break;
209
590
  }
210
591
  }
@@ -218,7 +599,7 @@ function forward(event, props) {
218
599
  * `loading="lazy"` 在这里不影响抢首帧:它只推迟**视口外**的图,而封面天然在视口内。
219
600
  */
220
601
  function HostPoster({ poster }) {
221
- if (poster === void 0) return null;
602
+ if (poster == null) return null;
222
603
  const url = typeof poster === "string" ? poster : poster.url;
223
604
  if (!url) return null;
224
605
  const fit = typeof poster === "string" ? void 0 : poster.fit;
@@ -234,7 +615,8 @@ function HostPoster({ poster }) {
234
615
  height: "100%",
235
616
  display: "block",
236
617
  objectFit: fit ?? "cover",
237
- zIndex: 10
618
+ zIndex: 10,
619
+ pointerEvents: "none"
238
620
  }
239
621
  });
240
622
  }
@@ -254,11 +636,16 @@ function HostPoster({ poster }) {
254
636
  * <VideoPlayerFrame ref={ref} source="https://cdn/a.m3u8" muted onReady={...} />
255
637
  * ref.current?.play()
256
638
  */
257
- const VideoPlayerFrame = (0, react.forwardRef)(function VideoPlayerFrame(props, ref) {
258
- const { containerRef, handleRef, mounted, hostPosterVisible } = useFrameConnection(props);
639
+ const VideoPlayerFrame = (0, react.forwardRef)(function VideoPlayerFrame(rawProps, ref) {
640
+ const props = (0, _video_lab_react_config.useResolvedPlayerProps)(rawProps, true);
641
+ const [hasCustomPoster] = (0, react.useState)(() => props.renderPoster !== void 0);
642
+ const [hasCustomPause] = (0, react.useState)(() => props.renderPause !== void 0);
643
+ const [localeOverride, setLocaleOverride] = (0, react.useState)();
644
+ (0, react.useEffect)(() => setLocaleOverride(void 0), [JSON.stringify(props.locale)]);
645
+ const { containerRef, handleRef, mounted, hostPosterVisible, hostPauseVisible, pause, playIntent, loadingOverlay, terminalError, hostOwnsOverlays, performErrorAction, cancelSourceSwitch } = useFrameConnection(props, hasCustomPoster, hasCustomPause);
259
646
  (0, react.useImperativeHandle)(ref, () => ({
260
647
  play: () => handleRef.current?.play() ?? Promise.resolve(),
261
- pause: () => handleRef.current?.pause() ?? Promise.resolve(),
648
+ pause,
262
649
  seek: (time) => handleRef.current?.seek(time) ?? Promise.resolve(),
263
650
  setMuted: (muted) => handleRef.current?.setMuted(muted) ?? Promise.resolve(),
264
651
  setVolume: (volume) => handleRef.current?.setVolume(volume) ?? Promise.resolve(),
@@ -268,37 +655,191 @@ const VideoPlayerFrame = (0, react.forwardRef)(function VideoPlayerFrame(props,
268
655
  exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),
269
656
  setQuality: (level) => handleRef.current?.setQuality(level) ?? Promise.resolve(),
270
657
  setSubtitle: (id) => handleRef.current?.setSubtitle(id) ?? Promise.resolve(),
271
- setLocale: (locale) => handleRef.current?.setLocale(locale) ?? Promise.resolve(),
658
+ setLocale: (locale) => {
659
+ setLocaleOverride(locale);
660
+ return handleRef.current?.setLocale(locale) ?? Promise.resolve();
661
+ },
272
662
  pushDanmaku: (item) => handleRef.current?.pushDanmaku(item) ?? Promise.resolve(),
273
663
  setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled) ?? Promise.resolve(),
274
664
  clearDanmaku: () => handleRef.current?.clearDanmaku() ?? Promise.resolve(),
275
665
  retry: () => handleRef.current?.retry() ?? Promise.resolve(),
276
- destroy: () => handleRef.current?.destroy() ?? Promise.resolve(),
666
+ performErrorAction,
667
+ destroy: () => {
668
+ cancelSourceSwitch("destroyed");
669
+ return handleRef.current?.destroy() ?? Promise.resolve();
670
+ },
277
671
  getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,
278
672
  getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,
279
673
  getDuration: () => handleRef.current?.getDuration() ?? 0,
280
674
  getPlayerHandle: () => handleRef.current
281
- }), [handleRef]);
675
+ }), [
676
+ handleRef,
677
+ cancelSourceSwitch,
678
+ pause,
679
+ performErrorAction
680
+ ]);
282
681
  const style = {
283
682
  position: "relative",
683
+ display: "block",
684
+ margin: 0,
284
685
  width: "100%",
285
686
  height: "100%",
286
- ...props.interactive === false ? { pointerEvents: "none" } : {},
687
+ ...(props.interactive ?? (props.preset ? _video_lab_protocol.PRESETS[props.preset].interactive : void 0)) === false ? { pointerEvents: "none" } : {},
287
688
  ...props.style
288
689
  };
690
+ const className = ["sentinel-video-frame", props.className].filter(Boolean).join(" ");
691
+ const effectiveLocale = localeOverride ?? props.locale;
692
+ const lang = (0, _video_lab_protocol.normalizeLocaleTag)((typeof effectiveLocale === "string" ? effectiveLocale : effectiveLocale?.locale) || "zh-CN");
693
+ const loadingContext = loadingOverlay && !((loadingOverlay.reason === "initializing" || loadingOverlay.reason === "ok") && !playIntent) ? {
694
+ ...loadingOverlay,
695
+ text: (0, _video_lab_protocol.resolveOverlayMessage)((0, _video_lab_protocol.resolveLocaleMessages)(effectiveLocale).messages, `loading.${loadingOverlay.reason}`, lang)
696
+ } : null;
697
+ const { messages } = (0, _video_lab_protocol.resolveLocaleMessages)(effectiveLocale);
698
+ const showHostLoading = loadingContext && !props.renderLoading && props.showDefaultLoadingOverlay !== false && (loadingContext.reason === "frame_disconnected" || hostOwnsOverlays);
699
+ const showHostError = terminalError && props.showErrorOverlay !== false && (terminalError.reason === "frame_disconnected" || terminalError.synthetic === true || hostOwnsOverlays && terminalError.reason === "error");
700
+ const errorActionKey = (0, _video_lab_protocol.errorActionMessageKey)(terminalError ? (0, _video_lab_protocol.effectiveErrorAction)({
701
+ reason: terminalError.reason,
702
+ recoverable: false,
703
+ action: terminalError.action,
704
+ errorCode: terminalError.error.code,
705
+ canResolveSource: Boolean(props.resolveSource),
706
+ canRecreateFrame: true
707
+ }) : "none", terminalError?.error.code);
289
708
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
290
709
  ref: containerRef,
291
- className: ["sentinel-video-frame", props.className].filter(Boolean).join(" "),
710
+ className,
292
711
  style,
293
- children: [!mounted && (props.children ?? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
294
- className: "sentinel-video-frame__placeholder",
295
- style: {
296
- width: "100%",
297
- height: "100%",
298
- background: "#000"
299
- }
300
- })), hostPosterVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostPoster, { poster: props.poster })]
712
+ lang,
713
+ children: [
714
+ !mounted && (props.children ?? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
715
+ className: "sentinel-video-frame__placeholder",
716
+ style: {
717
+ width: "100%",
718
+ height: "100%",
719
+ background: "#000"
720
+ }
721
+ })),
722
+ !hasCustomPoster && hostPosterVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostPoster, { poster: props.poster }),
723
+ hasCustomPoster && hostPosterVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
724
+ "data-sentinel-overlay": "custom-poster",
725
+ style: {
726
+ position: "absolute",
727
+ inset: 0,
728
+ zIndex: 10
729
+ },
730
+ children: props.renderPoster?.()
731
+ }),
732
+ hasCustomPause && hostPauseVisible && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
733
+ "data-sentinel-overlay": "custom-pause",
734
+ style: {
735
+ position: "absolute",
736
+ inset: 0,
737
+ zIndex: 13,
738
+ display: "flex",
739
+ alignItems: "center",
740
+ justifyContent: "center"
741
+ },
742
+ children: props.renderPause?.()
743
+ }),
744
+ props.renderLoading && loadingContext && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
745
+ className: "sentinel-video-frame__custom-loading",
746
+ "data-sentinel-overlay": "custom-loading",
747
+ style: {
748
+ position: "absolute",
749
+ inset: 0,
750
+ zIndex: 12,
751
+ display: "flex",
752
+ alignItems: "center",
753
+ justifyContent: "center",
754
+ pointerEvents: "none"
755
+ },
756
+ children: props.renderLoading(loadingContext)
757
+ }),
758
+ showHostLoading && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
759
+ "data-sentinel-overlay": "loading",
760
+ role: "status",
761
+ "aria-label": loadingContext.text,
762
+ style: {
763
+ position: "absolute",
764
+ inset: 0,
765
+ zIndex: 12,
766
+ display: "flex",
767
+ alignItems: "center",
768
+ justifyContent: "center",
769
+ flexDirection: "column",
770
+ gap: 8,
771
+ color: "#fff",
772
+ background: "rgba(0,0,0,.5)",
773
+ pointerEvents: "none"
774
+ },
775
+ children: [
776
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: "@keyframes sentinel-frame-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.sentinel-frame-spinner{animation:none!important}}" }),
777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
778
+ className: "sentinel-frame-spinner",
779
+ "aria-hidden": "true",
780
+ style: {
781
+ width: 24,
782
+ height: 24,
783
+ border: "2px solid rgba(255,255,255,.35)",
784
+ borderTopColor: "#fff",
785
+ borderRadius: "50%",
786
+ animation: "sentinel-frame-spin .8s linear infinite"
787
+ }
788
+ }),
789
+ props.showLoadingText === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
790
+ part: "text",
791
+ children: loadingContext.text
792
+ })
793
+ ]
794
+ }),
795
+ showHostError && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
796
+ "data-sentinel-overlay": "error",
797
+ role: "alert",
798
+ style: {
799
+ position: "absolute",
800
+ inset: 0,
801
+ zIndex: 15,
802
+ display: "flex",
803
+ alignItems: "center",
804
+ justifyContent: "center",
805
+ flexDirection: "column",
806
+ gap: 12,
807
+ padding: 16,
808
+ color: "#fff",
809
+ background: "rgba(0,0,0,.8)",
810
+ textAlign: "center"
811
+ },
812
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
813
+ part: "message",
814
+ children: (0, _video_lab_protocol.resolveErrorMessage)(messages, terminalError.error.code, void 0, lang)
815
+ }), errorActionKey && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
816
+ type: "button",
817
+ onClick: () => void performErrorAction().catch(() => {}),
818
+ style: {
819
+ border: 0,
820
+ borderRadius: 6,
821
+ padding: "8px 16px",
822
+ background: props.errorButtonColors?.background ?? "#3B82F6",
823
+ color: props.errorButtonColors?.foreground ?? "#fff",
824
+ cursor: "pointer"
825
+ },
826
+ children: (0, _video_lab_protocol.resolveOverlayMessage)(messages, errorActionKey, lang)
827
+ })]
828
+ })
829
+ ]
301
830
  });
302
831
  });
303
832
  //#endregion
833
+ Object.defineProperty(exports, "PlayerProvider", {
834
+ enumerable: true,
835
+ get: function() {
836
+ return _video_lab_react_config.PlayerProvider;
837
+ }
838
+ });
304
839
  exports.VideoPlayerFrame = VideoPlayerFrame;
840
+ Object.defineProperty(exports, "usePlayerI18n", {
841
+ enumerable: true,
842
+ get: function() {
843
+ return _video_lab_react_config.usePlayerI18n;
844
+ }
845
+ });