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