@video-lab/react-frame 3.0.1 → 4.0.0

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