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