@video-lab/react 1.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.cjs +447 -0
- package/dist/index.d.cts +310 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +310 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +448 -0
- package/dist/index.mjs.map +1 -0
- package/dist/style.css +124 -0
- package/package.json +62 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
2
|
+
import { INITIAL_OVERLAY, defineSentinelOverlays, reduceOverlay, resetOverlayForNewSource } from "@video-lab/player-ui";
|
|
3
|
+
import { PLAYER_DESTROYED_MESSAGE, makePlayerError, resolveLocaleMessages, toErrorEvent } from "@video-lab/protocol";
|
|
4
|
+
import { createPlayer } from "@video-lab/player-core";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/overlay.ts
|
|
7
|
+
/**
|
|
8
|
+
* 从 LocaleConfig 里取翻译表。实现住在 protocol —— iframe 面要做同样的事,
|
|
9
|
+
* 此前两边各有一份逐字相同的副本,是四面行为漂移的经典来源。
|
|
10
|
+
*/
|
|
11
|
+
const readLocale = resolveLocaleMessages;
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/overlay-elements.tsx
|
|
14
|
+
/**
|
|
15
|
+
* `createElement` 要求 tag 是 `keyof JSX.IntrinsicElements`,而 `@types/react@19`
|
|
16
|
+
* **仍然不认识** custom element(实测:`Property 'sentinel-poster' does not exist on type
|
|
17
|
+
* 'JSX.IntrinsicElements'`)。
|
|
18
|
+
*
|
|
19
|
+
* **不做全局 JSX 命名空间增补** —— 那会污染所有消费方的类型环境,库不该干这事
|
|
20
|
+
* (对照:media-chrome 也不增补,它另外发一套 `/react` 包装)。
|
|
21
|
+
* 逃逸收敛在这一行,外面全是有类型的。
|
|
22
|
+
*/
|
|
23
|
+
const createOverlayElement = createElement;
|
|
24
|
+
/**
|
|
25
|
+
* 渲染一个覆盖层元素。
|
|
26
|
+
*
|
|
27
|
+
* 这不是"包装组件",只是一个带类型的 `createElement` 调用 + 一次幂等注册 ——
|
|
28
|
+
* React 19 之后没有别的事要做了。
|
|
29
|
+
*/
|
|
30
|
+
function overlayElement(tag, props) {
|
|
31
|
+
defineSentinelOverlays();
|
|
32
|
+
const { children, ...rest } = props;
|
|
33
|
+
return createOverlayElement(tag, rest, children);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 取文案。**语义和原 `useT()` 逐字一致:key 找不到就返回 key 本身。**
|
|
37
|
+
*
|
|
38
|
+
* 不 throw 也不返回空串 —— 漏翻译时界面上会明晃晃出现 `error.E_NETWORK` 这种字样,一眼能发现;
|
|
39
|
+
* throw 会白屏,空串是静默失败,最难排查。
|
|
40
|
+
*
|
|
41
|
+
* i18n 解析在 PR B 里上移到了这一层(spec § 技术方案 ②):元素只收解析好的字符串,
|
|
42
|
+
* 于是 player-ui 不再需要任何状态注入机制。
|
|
43
|
+
*/
|
|
44
|
+
function resolveText(messages, key) {
|
|
45
|
+
return messages[key] ?? key;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/use-player.ts
|
|
49
|
+
/**
|
|
50
|
+
* 管理 player-core 的整个生命周期,并把播放器事件同时喂给覆盖层状态机和 React 回调 props。
|
|
51
|
+
*
|
|
52
|
+
* 和 react-frame 的 `useFrameConnection` 结构对齐,差别在于 inline 直连 player-core:
|
|
53
|
+
* - `createPlayer` 是**同步**的(不像 `createPlayerFrame` 返回 Promise),但会**同步抛错**
|
|
54
|
+
* (选源失败,比如 iOS 只给了 FLV)——必须 try/catch 转成 error 覆盖层 + onError。
|
|
55
|
+
* - 覆盖层(Poster / Loading / Error)由本包渲染,所以事件要先更新 overlay 再转发回调
|
|
56
|
+
* (iframe 系的覆盖层在 embed-app 内,薄壳不管)。
|
|
57
|
+
*
|
|
58
|
+
* **事件桥接关键**:onEvent 闭包只在挂载时建一次,但要调**最新**回调 props——用 `propsRef`
|
|
59
|
+
* 存最新 props,`forward` 从 `propsRef.current` 取,而不是闭包里捕获的旧值。
|
|
60
|
+
* SSR 安全:`createPlayer` 只在 useEffect(客户端)里跑。
|
|
61
|
+
*/
|
|
62
|
+
function usePlayer(props) {
|
|
63
|
+
const containerRef = useRef(null);
|
|
64
|
+
const handleRef = useRef(null);
|
|
65
|
+
const [overlay, setOverlay] = useState(INITIAL_OVERLAY);
|
|
66
|
+
const [mounted, setMounted] = useState(false);
|
|
67
|
+
const [rebuildKey, setRebuildKey] = useState(0);
|
|
68
|
+
const propsRef = useRef(props);
|
|
69
|
+
propsRef.current = props;
|
|
70
|
+
/**
|
|
71
|
+
* 三条 prop effect 的 skip-first-run 标志位。
|
|
72
|
+
*
|
|
73
|
+
* **必须在 build effect 的 cleanup 里复位**(#720):StrictMode dev 下 React 对整棵子树
|
|
74
|
+
* 做 setup → cleanup → setup,而这三个 ref **此前没有任何复位点**。第二遍 setup 时
|
|
75
|
+
* build effect 先重建出一个新 player,紧接着这三条 effect 看到标志位已是 `true`,
|
|
76
|
+
* 于是对一个**刚建好、config 里已带同样值**的播放器再下发一次
|
|
77
|
+
* `load()` / `setMuted()` / `setLocale()`。
|
|
78
|
+
*
|
|
79
|
+
* 后果不只是「画面闪、缓冲白扔」—— `load()` 成功会开**新 session**(ADR-074),
|
|
80
|
+
* dev 下每次挂载都在遥测里多出一段幽灵会话,`contextchange` 的 `sessionId` 对不上。
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ **Vue 无此形态**(`watch` 默认不 immediate),**react-frame 也免疫**
|
|
83
|
+
* (`createPlayerFrame` 异步,第二遍 setup 时句柄仍是 null)。
|
|
84
|
+
* inline 因为 `createPlayer` 是**同步**的才需要这三个标志位,也因此才有这个洞。
|
|
85
|
+
*/
|
|
86
|
+
const sourceInited = useRef(false);
|
|
87
|
+
const mutedInited = useRef(false);
|
|
88
|
+
const localeInited = useRef(false);
|
|
89
|
+
/**
|
|
90
|
+
* 错误出口。**建 player 失败(同步抛)和换源失败(异步 reject)必须走同一条路**:
|
|
91
|
+
* 错误覆盖层 + `onError`。分成两条会出现"换源失败时组件毫无反应"的静默失败。
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ **必须走 `forward()`,不能直接调 `onError`。** 这里曾经是
|
|
94
|
+
* `propsRef.current.onError?.(payload)` —— 于是这条 error **进不了 `onPlayerEvent`**,
|
|
95
|
+
* 而两个 iframe 面的同一条失败路径走的就是 `forward(...)`(见
|
|
96
|
+
* `react-frame/src/use-frame-connection.ts` 的 `.catch(err => forward(...))`)。
|
|
97
|
+
*
|
|
98
|
+
* 后果是 parity 破口:消费方按 ADR-080 接 `onPlayerEvent` 做遥测时,
|
|
99
|
+
* **iframe 两面收得到这条 error,inline 两面一条都收不到** —— 而 `types.ts` 对那个 prop
|
|
100
|
+
* 的原话是「完整的契约事件流,供遥测适配器使用;不裁剪 payload」。
|
|
101
|
+
*
|
|
102
|
+
* 命中的两条真实路径:`createPlayer` 同步抛(iOS 只给 FLV)、ADR-083 的构造期签名拒绝。
|
|
103
|
+
*/
|
|
104
|
+
const reportError = useCallback((err) => {
|
|
105
|
+
const errorEvent = toErrorEvent(err);
|
|
106
|
+
setOverlay((prev) => reduceOverlay(prev, errorEvent));
|
|
107
|
+
forward(errorEvent, propsRef.current);
|
|
108
|
+
}, []);
|
|
109
|
+
/**
|
|
110
|
+
* **prop 驱动**的命令统一出口(ADR-067 · #416)。
|
|
111
|
+
*
|
|
112
|
+
* 只多做一件事:句柄**已销毁**就报错、不下发 —— 因为 `create-player.ts` 里
|
|
113
|
+
* 每个命令开头的 `if (destroyed) return` 会让它**静默空转**。那个 `return` 是对的
|
|
114
|
+
*(坑 #3:destroy 之后不许再有事件出去),错的是上一层从来不说话:实测消费方
|
|
115
|
+
* 调完 `destroy()` 再改 `source`,inline 面 **0 条事件**,而 iframe 面报了一条。
|
|
116
|
+
*
|
|
117
|
+
* ⚠️ **句柄为 `null` 时静默是对的**,别顺手也报 —— `null` 意味着「还没建成 / 已卸载」,
|
|
118
|
+
* 不是「被拆了」(ADR-066 事实 ③:卸载先 `destroy()` 再置 `null`)。
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ **三条 prop effect 都要走它**,别只管 `load`。只 gate 一条会造出新的 parity 差异:
|
|
121
|
+
* `setMuted` 在 iframe 有声、在 inline 静默 —— 用一个洞换另一个洞。
|
|
122
|
+
*/
|
|
123
|
+
const dispatch = useCallback((run) => {
|
|
124
|
+
const handle = handleRef.current;
|
|
125
|
+
if (!handle) return;
|
|
126
|
+
if (handle.destroyed) {
|
|
127
|
+
reportError({ playerError: makePlayerError("E_PLAYER_DESTROYED", PLAYER_DESTROYED_MESSAGE) });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
run(handle);
|
|
131
|
+
}, [reportError]);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const container = containerRef.current;
|
|
134
|
+
if (!container) return;
|
|
135
|
+
const p = propsRef.current;
|
|
136
|
+
setOverlay(INITIAL_OVERLAY);
|
|
137
|
+
try {
|
|
138
|
+
handleRef.current = createPlayer({
|
|
139
|
+
el: container,
|
|
140
|
+
config: toConfig(p),
|
|
141
|
+
onEvent: (event) => {
|
|
142
|
+
setOverlay((prev) => reduceOverlay(prev, event));
|
|
143
|
+
forward(event, propsRef.current);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
setMounted(true);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
reportError(err);
|
|
149
|
+
}
|
|
150
|
+
return () => {
|
|
151
|
+
handleRef.current?.destroy();
|
|
152
|
+
handleRef.current = null;
|
|
153
|
+
setMounted(false);
|
|
154
|
+
sourceInited.current = false;
|
|
155
|
+
mutedInited.current = false;
|
|
156
|
+
localeInited.current = false;
|
|
157
|
+
};
|
|
158
|
+
}, [rebuildKey]);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!sourceInited.current) {
|
|
161
|
+
sourceInited.current = true;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setOverlay(resetOverlayForNewSource);
|
|
165
|
+
dispatch((handle) => {
|
|
166
|
+
handle.load(props.source).catch(reportError);
|
|
167
|
+
});
|
|
168
|
+
}, [
|
|
169
|
+
JSON.stringify(props.source),
|
|
170
|
+
reportError,
|
|
171
|
+
dispatch
|
|
172
|
+
]);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!mutedInited.current) {
|
|
175
|
+
mutedInited.current = true;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const muted = props.muted;
|
|
179
|
+
if (muted !== void 0) dispatch((handle) => handle.setMuted(muted));
|
|
180
|
+
}, [props.muted, dispatch]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!localeInited.current) {
|
|
183
|
+
localeInited.current = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const locale = props.locale;
|
|
187
|
+
if (locale !== void 0) dispatch((handle) => handle.setLocale(locale));
|
|
188
|
+
}, [props.locale, dispatch]);
|
|
189
|
+
return {
|
|
190
|
+
containerRef,
|
|
191
|
+
handleRef,
|
|
192
|
+
overlay,
|
|
193
|
+
mounted,
|
|
194
|
+
handleRetry: useCallback(() => {
|
|
195
|
+
setRebuildKey((k) => k + 1);
|
|
196
|
+
}, []),
|
|
197
|
+
dismissPauseImage: useCallback(() => {
|
|
198
|
+
setOverlay((prev) => ({
|
|
199
|
+
...prev,
|
|
200
|
+
pauseImageVisible: false,
|
|
201
|
+
pauseImageDismissed: true
|
|
202
|
+
}));
|
|
203
|
+
}, [])
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* props → PlayerConfig。只带"显式传了的"字段;undefined 交给 player-core 走 preset / xgplayer 默认
|
|
208
|
+
*(resolvePreset 的语义就是 undefined = 没传)。source 直接进 config(createPlayer 的 config 含 source)。
|
|
209
|
+
*/
|
|
210
|
+
function toConfig(props) {
|
|
211
|
+
const config = { source: props.source };
|
|
212
|
+
if (props.preset !== void 0) config.preset = props.preset;
|
|
213
|
+
if (props.autoplay !== void 0) config.autoplay = props.autoplay;
|
|
214
|
+
if (props.muted !== void 0) config.muted = props.muted;
|
|
215
|
+
if (props.loop !== void 0) config.loop = props.loop;
|
|
216
|
+
if (props.playsinline !== void 0) config.playsinline = props.playsinline;
|
|
217
|
+
if (props.playbackRate !== void 0) config.playbackRate = props.playbackRate;
|
|
218
|
+
if (props.volume !== void 0) config.volume = props.volume;
|
|
219
|
+
if (props.startTime !== void 0) config.startTime = props.startTime;
|
|
220
|
+
if (props.preload !== void 0) config.preload = props.preload;
|
|
221
|
+
if (props.controls !== void 0) config.controls = props.controls;
|
|
222
|
+
if (props.interactive !== void 0) config.interactive = props.interactive;
|
|
223
|
+
if (props.poster !== void 0) config.poster = props.poster;
|
|
224
|
+
if (props.pauseImage !== void 0) config.pauseImage = props.pauseImage;
|
|
225
|
+
if (props.locale !== void 0) config.locale = props.locale;
|
|
226
|
+
if (props.danmaku !== void 0) config.danmaku = props.danmaku;
|
|
227
|
+
if (props.debug !== void 0) config.debug = props.debug;
|
|
228
|
+
return config;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 播放器事件 → React 回调。名字从 wire 上的小写换成 `onCamelCase`。
|
|
232
|
+
* 和 react-frame / vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload。
|
|
233
|
+
*
|
|
234
|
+
* **契约声明的 19 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
|
|
235
|
+
* 静态断言。曾经漏过 4 个(seeking / seeked / waiting / playing):前两个有注释说
|
|
236
|
+
* 「团队封装层没用到,需要时再加」,后两个连注释都没有 —— 被 `default: break` 静默吞掉。
|
|
237
|
+
* 而静态 iframe 模式(embed-app 裸广播,无过滤)19 个全发,于是同一份契约在四种接入方式
|
|
238
|
+
* 下能收到的事件不一样,parity 红线实际已破。别再以"业务暂时用不到"为由少接一个。
|
|
239
|
+
*/
|
|
240
|
+
function forward(event, props) {
|
|
241
|
+
try {
|
|
242
|
+
props.onPlayerEvent?.(event);
|
|
243
|
+
} catch {}
|
|
244
|
+
switch (event.event) {
|
|
245
|
+
case "ready":
|
|
246
|
+
props.onReady?.(event.payload);
|
|
247
|
+
break;
|
|
248
|
+
case "play":
|
|
249
|
+
props.onPlay?.();
|
|
250
|
+
break;
|
|
251
|
+
case "pause":
|
|
252
|
+
props.onPause?.();
|
|
253
|
+
break;
|
|
254
|
+
case "ended":
|
|
255
|
+
props.onEnded?.();
|
|
256
|
+
break;
|
|
257
|
+
case "timeupdate":
|
|
258
|
+
props.onTimeUpdate?.(event.payload);
|
|
259
|
+
break;
|
|
260
|
+
case "volumechange":
|
|
261
|
+
props.onVolumeChange?.(event.payload);
|
|
262
|
+
break;
|
|
263
|
+
case "seeking":
|
|
264
|
+
props.onSeeking?.(event.payload);
|
|
265
|
+
break;
|
|
266
|
+
case "seeked":
|
|
267
|
+
props.onSeeked?.(event.payload);
|
|
268
|
+
break;
|
|
269
|
+
case "waiting":
|
|
270
|
+
props.onWaiting?.();
|
|
271
|
+
break;
|
|
272
|
+
case "playing":
|
|
273
|
+
props.onPlaying?.();
|
|
274
|
+
break;
|
|
275
|
+
case "qualitychange":
|
|
276
|
+
props.onQualityChange?.(event.payload);
|
|
277
|
+
break;
|
|
278
|
+
case "subtitlechange":
|
|
279
|
+
props.onSubtitleChange?.(event.payload);
|
|
280
|
+
break;
|
|
281
|
+
case "error":
|
|
282
|
+
props.onError?.(event.payload);
|
|
283
|
+
break;
|
|
284
|
+
case "autoplayblocked":
|
|
285
|
+
props.onAutoplayBlocked?.();
|
|
286
|
+
break;
|
|
287
|
+
case "reconnectstart":
|
|
288
|
+
props.onReconnectStart?.({
|
|
289
|
+
attempt: event.payload.attempt,
|
|
290
|
+
maxAttempts: event.payload.maxAttempts
|
|
291
|
+
});
|
|
292
|
+
break;
|
|
293
|
+
case "reconnectsuccess":
|
|
294
|
+
props.onReconnectSuccess?.();
|
|
295
|
+
break;
|
|
296
|
+
case "reconnectfailed":
|
|
297
|
+
props.onReconnectFailed?.();
|
|
298
|
+
break;
|
|
299
|
+
case "compatwarning":
|
|
300
|
+
props.onCompatWarning?.(event.payload);
|
|
301
|
+
break;
|
|
302
|
+
case "stalled":
|
|
303
|
+
props.onStalled?.(event.payload);
|
|
304
|
+
break;
|
|
305
|
+
case "playablechange":
|
|
306
|
+
props.onPlayableChange?.(event.payload);
|
|
307
|
+
break;
|
|
308
|
+
case "bufferhealth":
|
|
309
|
+
props.onBufferHealth?.(event.payload);
|
|
310
|
+
break;
|
|
311
|
+
case "contextchange":
|
|
312
|
+
props.onContextChange?.(event.payload);
|
|
313
|
+
break;
|
|
314
|
+
case "sourceroute":
|
|
315
|
+
props.onSourceRoute?.(event.payload);
|
|
316
|
+
break;
|
|
317
|
+
case "firstframe":
|
|
318
|
+
props.onFirstFrame?.(event.payload);
|
|
319
|
+
break;
|
|
320
|
+
case "framefreeze":
|
|
321
|
+
props.onFrameFreeze?.(event.payload);
|
|
322
|
+
break;
|
|
323
|
+
case "useraction":
|
|
324
|
+
props.onUserAction?.(event.payload);
|
|
325
|
+
break;
|
|
326
|
+
case "kernelhealth":
|
|
327
|
+
props.onKernelHealth?.(event.payload);
|
|
328
|
+
break;
|
|
329
|
+
case "audiohealth":
|
|
330
|
+
props.onAudioHealth?.(event.payload);
|
|
331
|
+
break;
|
|
332
|
+
case "recovery": break;
|
|
333
|
+
default: break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/VideoPlayer.tsx
|
|
338
|
+
/**
|
|
339
|
+
* VideoPlayer · React inline 高性能模式(SDK 消费面之一)
|
|
340
|
+
*
|
|
341
|
+
* 直连 player-core + player-ui,不走 iframe——首帧最快(~200ms),数据路径和 iframe 系不同。
|
|
342
|
+
*
|
|
343
|
+
* **覆盖层用 player-ui 的 `<sentinel-*>` custom elements**(#120 · R1 · PR B),不再是 React 组件。
|
|
344
|
+
* 消费方 API 一个都没变 —— props / 事件 / 句柄全部原样。变的只有两件内部事:
|
|
345
|
+
* ① i18n 解析上移到这一层(元素只收解析好的字符串,player-ui 不再需要状态注入机制);
|
|
346
|
+
* ② 覆盖层的样式钩子从全局 class 变成 shadow DOM 的 `::part()`(见 USER-GUIDE)。
|
|
347
|
+
* 只做技术:组装播放器、把 props 桥接成命令、把事件桥接成 `onXxx` 回调、渲染 SDK 自带的
|
|
348
|
+
* Poster / Loading / Error 覆盖层。不含任何团队品牌 UI(ADR-021)——那些在业务封装层。
|
|
349
|
+
* props / 事件面和 react-frame 对齐(cross-mode-parity)。
|
|
350
|
+
*
|
|
351
|
+
* 【关于默认值】刻意不给 autoplay / muted / loop / controls / playsinline / interactive
|
|
352
|
+
* 设默认值:这些字段同时受 preset 控制,一旦在这里强塞就变成"显式值"把 preset 覆盖掉
|
|
353
|
+
*(resolvePreset 的语义是 undefined 才回落到 preset)。没传的 prop 保持 undefined。
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* const ref = useRef<VideoPlayerHandle>(null)
|
|
357
|
+
* <VideoPlayer ref={ref} source="https://cdn/a.m3u8" muted onReady={...} />
|
|
358
|
+
* ref.current?.play()
|
|
359
|
+
*/
|
|
360
|
+
const VideoPlayer = forwardRef(function VideoPlayer(props, ref) {
|
|
361
|
+
const { containerRef, handleRef, overlay, mounted, handleRetry, dismissPauseImage } = usePlayer(props);
|
|
362
|
+
useImperativeHandle(ref, () => ({
|
|
363
|
+
play: () => handleRef.current?.play() ?? Promise.resolve(),
|
|
364
|
+
pause: () => handleRef.current?.pause(),
|
|
365
|
+
seek: (time) => handleRef.current?.seek(time),
|
|
366
|
+
setMuted: (muted) => handleRef.current?.setMuted(muted),
|
|
367
|
+
setVolume: (volume) => handleRef.current?.setVolume(volume),
|
|
368
|
+
setPlaybackRate: (rate) => handleRef.current?.setPlaybackRate(rate),
|
|
369
|
+
enterFullscreen: () => handleRef.current?.enterFullscreen() ?? Promise.resolve(),
|
|
370
|
+
exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),
|
|
371
|
+
setQuality: (level) => handleRef.current?.setQuality(level),
|
|
372
|
+
setSubtitle: (id) => handleRef.current?.setSubtitle(id),
|
|
373
|
+
setLocale: (locale) => handleRef.current?.setLocale(locale),
|
|
374
|
+
pushDanmaku: (item) => handleRef.current?.pushDanmaku(item),
|
|
375
|
+
setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled),
|
|
376
|
+
clearDanmaku: () => handleRef.current?.clearDanmaku(),
|
|
377
|
+
reconnect: (options) => handleRef.current?.reconnect(options),
|
|
378
|
+
destroy: () => handleRef.current?.destroy(),
|
|
379
|
+
getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,
|
|
380
|
+
getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,
|
|
381
|
+
getDuration: () => handleRef.current?.getDuration() ?? 0,
|
|
382
|
+
getPlayerHandle: () => handleRef.current
|
|
383
|
+
}), [handleRef]);
|
|
384
|
+
const { messages } = readLocale(props.locale);
|
|
385
|
+
const poster = props.poster;
|
|
386
|
+
const posterUrl = typeof poster === "string" ? poster : poster?.url;
|
|
387
|
+
const pauseImage = props.pauseImage;
|
|
388
|
+
const pauseImageUrl = typeof pauseImage === "string" ? pauseImage : pauseImage?.url;
|
|
389
|
+
const style = {
|
|
390
|
+
position: "relative",
|
|
391
|
+
width: "100%",
|
|
392
|
+
height: "100%",
|
|
393
|
+
...props.interactive === false ? { pointerEvents: "none" } : {},
|
|
394
|
+
...props.style
|
|
395
|
+
};
|
|
396
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
397
|
+
className: ["sentinel-video", props.className].filter(Boolean).join(" "),
|
|
398
|
+
style,
|
|
399
|
+
children: [
|
|
400
|
+
/* @__PURE__ */ jsx("div", {
|
|
401
|
+
ref: containerRef,
|
|
402
|
+
className: "sentinel-video__player",
|
|
403
|
+
style: {
|
|
404
|
+
width: "100%",
|
|
405
|
+
height: "100%"
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
posterUrl && overlayElement("sentinel-poster", {
|
|
409
|
+
visible: overlay.posterVisible,
|
|
410
|
+
poster
|
|
411
|
+
}),
|
|
412
|
+
pauseImageUrl && overlayElement("sentinel-pause", {
|
|
413
|
+
visible: overlay.pauseImageVisible,
|
|
414
|
+
pauseImage,
|
|
415
|
+
"close-label": resolveText(messages, "close"),
|
|
416
|
+
"onsentinel-close": dismissPauseImage
|
|
417
|
+
}),
|
|
418
|
+
overlayElement("sentinel-loading", {
|
|
419
|
+
visible: overlay.loadingVisible,
|
|
420
|
+
text: resolveText(messages, "loading")
|
|
421
|
+
}),
|
|
422
|
+
overlay.error && overlayElement("sentinel-error", {
|
|
423
|
+
visible: true,
|
|
424
|
+
code: overlay.error.code,
|
|
425
|
+
message: resolveErrorText(messages, overlay.error.code, overlay.error.message),
|
|
426
|
+
retryable: overlay.error.retryable,
|
|
427
|
+
"retry-label": resolveText(messages, "retry"),
|
|
428
|
+
"onsentinel-retry": handleRetry
|
|
429
|
+
}),
|
|
430
|
+
!mounted && props.children
|
|
431
|
+
]
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
/**
|
|
435
|
+
* 错误文案:`messages['error.<CODE>']` → `error.message`。
|
|
436
|
+
*
|
|
437
|
+
* `resolveText` 的语义是「找不到就返回 key」,所以这里要拿返回值和 key 比,
|
|
438
|
+
* 以此判断「消费方没注入这条翻译」—— 和原 `ErrorOverlay` 里那段逐字同构。
|
|
439
|
+
*/
|
|
440
|
+
function resolveErrorText(messages, code, fallback) {
|
|
441
|
+
const key = `error.${code}`;
|
|
442
|
+
const translated = resolveText(messages, key);
|
|
443
|
+
return translated === key ? fallback : translated;
|
|
444
|
+
}
|
|
445
|
+
//#endregion
|
|
446
|
+
export { VideoPlayer };
|
|
447
|
+
|
|
448
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/overlay.ts","../src/overlay-elements.tsx","../src/use-player.ts","../src/VideoPlayer.tsx"],"sourcesContent":["/**\n * 覆盖层相关的共享逻辑 —— **本模块只做转发,不再有实现**。\n *\n * 状态机搬到了 `player-ui`、`toErrorEvent` 搬到了 `protocol`(#120 · PR C):\n * 两者此前在 `apps/embed-app` 里各有一份逐字相同的副本,而 inline 和 iframe 内部\n * 必须对同一批事件做完全相同的决策。留这个转发模块是为了本包既有的调用点不用改 import。\n */\nexport {\n dismissPauseImage,\n INITIAL_OVERLAY,\n type OverlayState,\n reduceOverlay,\n resetOverlayForNewSource,\n} from '@video-lab/player-ui'\nexport { toErrorEvent } from '@video-lab/protocol'\n\nimport { resolveLocaleMessages } from '@video-lab/protocol'\n\n/**\n * 从 LocaleConfig 里取翻译表。实现住在 protocol —— iframe 面要做同样的事,\n * 此前两边各有一份逐字相同的副本,是四面行为漂移的经典来源。\n */\nexport const readLocale = resolveLocaleMessages\n","import { defineSentinelOverlays } from '@video-lab/player-ui'\nimport type { ResolvedMessages } from '@video-lab/protocol'\nimport { createElement, type ReactElement, type ReactNode } from 'react'\n\n/**\n * player-ui 的 `<sentinel-*>` custom elements 在 React 侧的接线(#120 · R1 · PR B)。\n *\n * **SDK peer 是 `react >=19`,所以这里几乎什么都不用做。** React 19 原生支持 custom element:\n * 实测(19.2.7,元素用的是**原型访问器**而非构造函数自有属性,和 spec 的例子不同)——\n *\n * | 写法 | React 19 行为 |\n * |---|---|\n * | `visible={false}` | 走 property setter → **attribute 被移除**(不是 `visible=\"false\"`) |\n * | `fit={undefined}` | **移除 attribute**(不是字符串 `\"undefined\"`) |\n * | `poster={对象}` | 元素有同名 property 时**原样传对象**,不序列化 |\n * | `onsentinel-retry={fn}` | **接住 CustomEvent**(小写 `on` + 原样保留连字符) |\n *\n * (React 18 四条全不成立,所以 peer 从 `>=18` 提到了 `>=19`,是一次 breaking。)\n *\n * ⚠️ **本注释曾写「这四条和 Vue 3.5 逐条一致 —— 两个框架都不需要适配层」,第四条是错的。**\n * 2026-08-23 对 Vue 3.5.39 实测(`@vue/test-utils`,量的是 runtime-dom 的 patcher):\n * ①②③ 确实逐条一致,**④ 不一致** ——\n *\n * | 事件 prop 写法 | React 19 | Vue 3.5 |\n * |---|---|---|\n * | `onsentinel-retry`(全小写) | ✅ 接住 | ❌ **没接住** |\n * | `onSentinel-retry` | — | ✅ 接住 |\n * | `onSentinelRetry` | — | ✅ 接住 |\n *\n * **React 唯一能用的写法,恰好是 Vue 唯一不能用的那个。** 两边接线层各写各的,\n * 抹不平也不需要抹平(对消费方不可见:那边拿到的是 React prop / Vue emit)。\n * 数据与探针见 `archive/docs/specs/vue-inline.md` § 证据 ①、ADR-052 § 4.4。\n *\n * 剩下的只有两件 React 19 也解决不了的事:**TS 不认识这些标签**、**元素要注册**。\n */\n\nexport type OverlayTag =\n | 'sentinel-poster'\n | 'sentinel-loading'\n | 'sentinel-error'\n | 'sentinel-pause'\n\n/** `<sentinel-*>` 能收的 props。attribute 走字符串,`visible` 走 property,`on*` 走 CustomEvent */\nexport interface OverlayProps {\n visible?: boolean\n children?: ReactNode\n [attr: string]: unknown\n}\n\n/**\n * `createElement` 要求 tag 是 `keyof JSX.IntrinsicElements`,而 `@types/react@19`\n * **仍然不认识** custom element(实测:`Property 'sentinel-poster' does not exist on type\n * 'JSX.IntrinsicElements'`)。\n *\n * **不做全局 JSX 命名空间增补** —— 那会污染所有消费方的类型环境,库不该干这事\n * (对照:media-chrome 也不增补,它另外发一套 `/react` 包装)。\n * 逃逸收敛在这一行,外面全是有类型的。\n */\nconst createOverlayElement = createElement as unknown as (\n tag: OverlayTag,\n props: OverlayProps,\n children?: ReactNode,\n) => ReactElement\n\n/**\n * 渲染一个覆盖层元素。\n *\n * 这不是\"包装组件\",只是一个带类型的 `createElement` 调用 + 一次幂等注册 ——\n * React 19 之后没有别的事要做了。\n */\nexport function overlayElement(tag: OverlayTag, props: OverlayProps): ReactElement {\n // 幂等 + 极廉价(4 次 customElements.get),SSR 下空转返回 false。\n // 放在这里而不是模块求值期:模块顶层碰 `customElements` 会炸 SSR / `next build`。\n defineSentinelOverlays()\n const { children, ...rest } = props\n return createOverlayElement(tag, rest, children)\n}\n\n/**\n * 取文案。**语义和原 `useT()` 逐字一致:key 找不到就返回 key 本身。**\n *\n * 不 throw 也不返回空串 —— 漏翻译时界面上会明晃晃出现 `error.E_NETWORK` 这种字样,一眼能发现;\n * throw 会白屏,空串是静默失败,最难排查。\n *\n * i18n 解析在 PR B 里上移到了这一层(spec § 技术方案 ②):元素只收解析好的字符串,\n * 于是 player-ui 不再需要任何状态注入机制。\n */\nexport function resolveText(messages: ResolvedMessages, key: string): string {\n return messages[key] ?? key\n}\n","import { createPlayer, type PlayerCoreHandle } from '@video-lab/player-core'\nimport {\n type MediaSource,\n makePlayerError,\n PLAYER_DESTROYED_MESSAGE,\n type PlayerConfig,\n type PlayerEvent,\n} from '@video-lab/protocol'\nimport {\n type MutableRefObject,\n type RefObject,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport {\n INITIAL_OVERLAY,\n type OverlayState,\n reduceOverlay,\n resetOverlayForNewSource,\n toErrorEvent,\n} from './overlay'\nimport type { VideoPlayerProps } from './types'\n\n/**\n * 管理 player-core 的整个生命周期,并把播放器事件同时喂给覆盖层状态机和 React 回调 props。\n *\n * 和 react-frame 的 `useFrameConnection` 结构对齐,差别在于 inline 直连 player-core:\n * - `createPlayer` 是**同步**的(不像 `createPlayerFrame` 返回 Promise),但会**同步抛错**\n * (选源失败,比如 iOS 只给了 FLV)——必须 try/catch 转成 error 覆盖层 + onError。\n * - 覆盖层(Poster / Loading / Error)由本包渲染,所以事件要先更新 overlay 再转发回调\n * (iframe 系的覆盖层在 embed-app 内,薄壳不管)。\n *\n * **事件桥接关键**:onEvent 闭包只在挂载时建一次,但要调**最新**回调 props——用 `propsRef`\n * 存最新 props,`forward` 从 `propsRef.current` 取,而不是闭包里捕获的旧值。\n * SSR 安全:`createPlayer` 只在 useEffect(客户端)里跑。\n */\nexport function usePlayer(props: VideoPlayerProps): {\n // React 19 起 useRef<T>(null) 的 current 可空 → RefObject<T | null>(@types/react 19)\n containerRef: RefObject<HTMLDivElement | null>\n handleRef: MutableRefObject<PlayerCoreHandle | null>\n overlay: OverlayState\n mounted: boolean\n handleRetry: () => void\n dismissPauseImage: () => void\n} {\n const containerRef = useRef<HTMLDivElement>(null)\n const handleRef = useRef<PlayerCoreHandle | null>(null)\n const [overlay, setOverlay] = useState<OverlayState>(INITIAL_OVERLAY)\n const [mounted, setMounted] = useState(false)\n // 换 config 引用触发重建(retry 用);计数只为拿到一个新引用\n const [rebuildKey, setRebuildKey] = useState(0)\n\n const propsRef = useRef(props)\n propsRef.current = props\n\n /**\n * 三条 prop effect 的 skip-first-run 标志位。\n *\n * **必须在 build effect 的 cleanup 里复位**(#720):StrictMode dev 下 React 对整棵子树\n * 做 setup → cleanup → setup,而这三个 ref **此前没有任何复位点**。第二遍 setup 时\n * build effect 先重建出一个新 player,紧接着这三条 effect 看到标志位已是 `true`,\n * 于是对一个**刚建好、config 里已带同样值**的播放器再下发一次\n * `load()` / `setMuted()` / `setLocale()`。\n *\n * 后果不只是「画面闪、缓冲白扔」—— `load()` 成功会开**新 session**(ADR-074),\n * dev 下每次挂载都在遥测里多出一段幽灵会话,`contextchange` 的 `sessionId` 对不上。\n *\n * ⚠️ **Vue 无此形态**(`watch` 默认不 immediate),**react-frame 也免疫**\n * (`createPlayerFrame` 异步,第二遍 setup 时句柄仍是 null)。\n * inline 因为 `createPlayer` 是**同步**的才需要这三个标志位,也因此才有这个洞。\n */\n const sourceInited = useRef(false)\n const mutedInited = useRef(false)\n const localeInited = useRef(false)\n\n /**\n * 错误出口。**建 player 失败(同步抛)和换源失败(异步 reject)必须走同一条路**:\n * 错误覆盖层 + `onError`。分成两条会出现\"换源失败时组件毫无反应\"的静默失败。\n *\n * ⚠️ **必须走 `forward()`,不能直接调 `onError`。** 这里曾经是\n * `propsRef.current.onError?.(payload)` —— 于是这条 error **进不了 `onPlayerEvent`**,\n * 而两个 iframe 面的同一条失败路径走的就是 `forward(...)`(见\n * `react-frame/src/use-frame-connection.ts` 的 `.catch(err => forward(...))`)。\n *\n * 后果是 parity 破口:消费方按 ADR-080 接 `onPlayerEvent` 做遥测时,\n * **iframe 两面收得到这条 error,inline 两面一条都收不到** —— 而 `types.ts` 对那个 prop\n * 的原话是「完整的契约事件流,供遥测适配器使用;不裁剪 payload」。\n *\n * 命中的两条真实路径:`createPlayer` 同步抛(iOS 只给 FLV)、ADR-083 的构造期签名拒绝。\n */\n const reportError = useCallback((err: unknown) => {\n const errorEvent = toErrorEvent(err)\n setOverlay((prev) => reduceOverlay(prev, errorEvent))\n forward(errorEvent, propsRef.current)\n }, [])\n\n /**\n * **prop 驱动**的命令统一出口(ADR-067 · #416)。\n *\n * 只多做一件事:句柄**已销毁**就报错、不下发 —— 因为 `create-player.ts` 里\n * 每个命令开头的 `if (destroyed) return` 会让它**静默空转**。那个 `return` 是对的\n *(坑 #3:destroy 之后不许再有事件出去),错的是上一层从来不说话:实测消费方\n * 调完 `destroy()` 再改 `source`,inline 面 **0 条事件**,而 iframe 面报了一条。\n *\n * ⚠️ **句柄为 `null` 时静默是对的**,别顺手也报 —— `null` 意味着「还没建成 / 已卸载」,\n * 不是「被拆了」(ADR-066 事实 ③:卸载先 `destroy()` 再置 `null`)。\n *\n * ⚠️ **三条 prop effect 都要走它**,别只管 `load`。只 gate 一条会造出新的 parity 差异:\n * `setMuted` 在 iframe 有声、在 inline 静默 —— 用一个洞换另一个洞。\n */\n const dispatch = useCallback(\n (run: (handle: PlayerCoreHandle) => void) => {\n const handle = handleRef.current\n if (!handle) return\n if (handle.destroyed) {\n reportError({\n playerError: makePlayerError('E_PLAYER_DESTROYED', PLAYER_DESTROYED_MESSAGE),\n })\n return\n }\n run(handle)\n },\n [reportError],\n )\n\n // 挂载(及 retry)时建一次 player,卸载时销毁\n // biome-ignore lint/correctness/useExhaustiveDependencies: 刻意只在挂载 / rebuildKey 变化时重建,props 变化走下面各自的 effect\n useEffect(() => {\n const container = containerRef.current\n if (!container) return\n\n const p = propsRef.current\n setOverlay(INITIAL_OVERLAY)\n\n try {\n handleRef.current = createPlayer({\n el: container,\n config: toConfig(p),\n onEvent: (event: PlayerEvent) => {\n // 先更新自己的覆盖层,再转发给消费方(和 embed-app 同序)\n setOverlay((prev) => reduceOverlay(prev, event))\n forward(event, propsRef.current)\n },\n })\n setMounted(true)\n } catch (err) {\n // 选源失败在 createPlayer 里同步抛出,player 根本没建。展示错误覆盖层并回报 onError。\n reportError(err)\n }\n\n return () => {\n handleRef.current?.destroy()\n handleRef.current = null\n setMounted(false)\n // 播放器没了,下一次 setup 就是一次全新的「首次」—— 三个标志位跟着归零。\n // 少了这三行,StrictMode 的第二遍 setup 会重复下发一次命令(见上面的声明处)。\n sourceInited.current = false\n mutedInited.current = false\n localeInited.current = false\n }\n }, [rebuildKey])\n\n // source / muted 变了 → 下发命令。**必须跳过首次(挂载)运行**:createPlayer 是同步的,\n // 挂载时 handle 已就绪,而初始 source/muted 已经进了 config——不跳过会重复 load / setMuted\n //(iframe 系因为 createPlayerFrame 异步、挂载时 handle 还是 null,天然不会重复)。\n //\n // **依赖的是序列化后的值,不是引用**:`MediaSource` 是 wire schema(只含可 JSON 序列化字段),\n // 而消费方最自然的写法 `source={{ url, type: 'hls' }}` 每次渲染都产生新引用 ——\n // 按引用比较会让父组件每重渲染一次就 `switchURL` 一次(画面闪、缓冲白扔)。\n // \"换源\"在语义上就该看值。react-frame / vue-frame 三个消费面同处理(cross-mode-parity)。\n const sourceKey = JSON.stringify(props.source)\n // biome-ignore lint/correctness/useExhaustiveDependencies: 刻意用 sourceKey(值比较)替代 props.source(引用比较)\n useEffect(() => {\n if (!sourceInited.current) {\n sourceInited.current = true\n return\n }\n // 换源要把封面收回来 —— 否则第二个源明明还没开播,画面却是黑的。\n // 规则住在 `player-ui/overlay-state.ts`,和 vue inline 共用一份(两个 iframe 面\n // 各有自己的宿主侧封面,规则相同但那一层够不着 player-ui)。\n setOverlay(resetOverlayForNewSource)\n // `load` 会 reject:跨内核换源抛 E_METHOD_NOT_SUPPORTED、新源选不出来抛\n // E_MEDIA_NOT_SUPPORTED。此前这里是 `void ...load()`,rejection 无人接管 ——\n // 消费方收不到任何信号,只在 console 留一个 unhandled rejection。\n dispatch((handle) => {\n handle.load(props.source as MediaSource).catch(reportError)\n })\n }, [sourceKey, reportError, dispatch])\n\n useEffect(() => {\n if (!mutedInited.current) {\n mutedInited.current = true\n return\n }\n const muted = props.muted\n if (muted !== undefined) dispatch((handle) => handle.setMuted(muted))\n }, [props.muted, dispatch])\n\n // locale 变了 → 就地切语言(ADR-035)。**不重建播放器**,进度和缓冲都保住。\n // 和 source/muted 同样需要 skip-first-run:inline 的 handle 是同步就绪的,\n // 初始 locale 已经随 config 进了 createPlayer,不跳过会在挂载时白切一次。\n useEffect(() => {\n if (!localeInited.current) {\n localeInited.current = true\n return\n }\n const locale = props.locale\n if (locale !== undefined) dispatch((handle) => handle.setLocale(locale))\n }, [props.locale, dispatch])\n\n // 错误覆盖层的\"重试\":重建播放器(对齐 embed-app 的 handleRetry 语义)\n const handleRetry = useCallback(() => {\n setRebuildKey((k) => k + 1)\n }, [])\n\n // 暂停图的关闭按钮。dismissed 记在 overlay 状态里 —— 它随重建归零,\n // 语义正好是\"本次播放不再显示\"(对齐 embed-app 的 dismissPauseImage)\n const dismissPauseImage = useCallback(() => {\n setOverlay((prev) => ({ ...prev, pauseImageVisible: false, pauseImageDismissed: true }))\n }, [])\n\n return { containerRef, handleRef, overlay, mounted, handleRetry, dismissPauseImage }\n}\n\n/**\n * props → PlayerConfig。只带\"显式传了的\"字段;undefined 交给 player-core 走 preset / xgplayer 默认\n *(resolvePreset 的语义就是 undefined = 没传)。source 直接进 config(createPlayer 的 config 含 source)。\n */\nexport function toConfig(props: VideoPlayerProps): PlayerConfig {\n const config: PlayerConfig = { source: props.source as MediaSource }\n if (props.preset !== undefined) config.preset = props.preset\n if (props.autoplay !== undefined) config.autoplay = props.autoplay\n if (props.muted !== undefined) config.muted = props.muted\n if (props.loop !== undefined) config.loop = props.loop\n if (props.playsinline !== undefined) config.playsinline = props.playsinline\n if (props.playbackRate !== undefined) config.playbackRate = props.playbackRate\n if (props.volume !== undefined) config.volume = props.volume\n if (props.startTime !== undefined) config.startTime = props.startTime\n if (props.preload !== undefined) config.preload = props.preload\n if (props.controls !== undefined) config.controls = props.controls\n if (props.interactive !== undefined) config.interactive = props.interactive\n if (props.poster !== undefined) config.poster = props.poster\n if (props.pauseImage !== undefined) config.pauseImage = props.pauseImage\n if (props.locale !== undefined) config.locale = props.locale\n if (props.danmaku !== undefined) config.danmaku = props.danmaku\n if (props.debug !== undefined) config.debug = props.debug\n return config\n}\n\n/**\n * 播放器事件 → React 回调。名字从 wire 上的小写换成 `onCamelCase`。\n * 和 react-frame / vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload。\n *\n * **契约声明的 19 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`\n * 静态断言。曾经漏过 4 个(seeking / seeked / waiting / playing):前两个有注释说\n * 「团队封装层没用到,需要时再加」,后两个连注释都没有 —— 被 `default: break` 静默吞掉。\n * 而静态 iframe 模式(embed-app 裸广播,无过滤)19 个全发,于是同一份契约在四种接入方式\n * 下能收到的事件不一样,parity 红线实际已破。别再以\"业务暂时用不到\"为由少接一个。\n */\nfunction forward(event: PlayerEvent, props: VideoPlayerProps): void {\n try {\n props.onPlayerEvent?.(event)\n } catch {\n // 遥测 observer 不能影响播放器与既有 UI callbacks。\n }\n switch (event.event) {\n case 'ready':\n props.onReady?.(event.payload)\n break\n case 'play':\n props.onPlay?.()\n break\n case 'pause':\n props.onPause?.()\n break\n case 'ended':\n props.onEnded?.()\n break\n case 'timeupdate':\n props.onTimeUpdate?.(event.payload)\n break\n case 'volumechange':\n props.onVolumeChange?.(event.payload)\n break\n case 'seeking':\n props.onSeeking?.(event.payload)\n break\n case 'seeked':\n props.onSeeked?.(event.payload)\n break\n case 'waiting':\n props.onWaiting?.()\n break\n case 'playing':\n props.onPlaying?.()\n break\n case 'qualitychange':\n props.onQualityChange?.(event.payload)\n break\n case 'subtitlechange':\n props.onSubtitleChange?.(event.payload)\n break\n case 'error':\n props.onError?.(event.payload)\n break\n case 'autoplayblocked':\n props.onAutoplayBlocked?.()\n break\n case 'reconnectstart':\n props.onReconnectStart?.({\n attempt: event.payload.attempt,\n maxAttempts: event.payload.maxAttempts,\n })\n break\n case 'reconnectsuccess':\n props.onReconnectSuccess?.()\n break\n case 'reconnectfailed':\n props.onReconnectFailed?.()\n break\n case 'compatwarning':\n props.onCompatWarning?.(event.payload)\n break\n case 'stalled':\n props.onStalled?.(event.payload)\n break\n case 'playablechange':\n props.onPlayableChange?.(event.payload)\n break\n case 'bufferhealth':\n props.onBufferHealth?.(event.payload)\n break\n case 'contextchange':\n props.onContextChange?.(event.payload)\n break\n case 'sourceroute':\n props.onSourceRoute?.(event.payload)\n break\n case 'firstframe':\n props.onFirstFrame?.(event.payload)\n break\n case 'framefreeze':\n props.onFrameFreeze?.(event.payload)\n break\n case 'useraction':\n props.onUserAction?.(event.payload)\n break\n case 'kernelhealth':\n props.onKernelHealth?.(event.payload)\n break\n case 'audiohealth':\n props.onAudioHealth?.(event.payload)\n break\n // recovery 仅走 onPlayerEvent 原始遥测出口;不新增 UI callback(ADR-079)。\n case 'recovery':\n break\n default:\n break\n }\n}\n","import { type CSSProperties, forwardRef, useImperativeHandle } from 'react'\nimport { readLocale } from './overlay'\nimport { overlayElement, resolveText } from './overlay-elements'\nimport type { VideoPlayerHandle, VideoPlayerProps } from './types'\nimport { usePlayer } from './use-player'\n\n/**\n * VideoPlayer · React inline 高性能模式(SDK 消费面之一)\n *\n * 直连 player-core + player-ui,不走 iframe——首帧最快(~200ms),数据路径和 iframe 系不同。\n *\n * **覆盖层用 player-ui 的 `<sentinel-*>` custom elements**(#120 · R1 · PR B),不再是 React 组件。\n * 消费方 API 一个都没变 —— props / 事件 / 句柄全部原样。变的只有两件内部事:\n * ① i18n 解析上移到这一层(元素只收解析好的字符串,player-ui 不再需要状态注入机制);\n * ② 覆盖层的样式钩子从全局 class 变成 shadow DOM 的 `::part()`(见 USER-GUIDE)。\n * 只做技术:组装播放器、把 props 桥接成命令、把事件桥接成 `onXxx` 回调、渲染 SDK 自带的\n * Poster / Loading / Error 覆盖层。不含任何团队品牌 UI(ADR-021)——那些在业务封装层。\n * props / 事件面和 react-frame 对齐(cross-mode-parity)。\n *\n * 【关于默认值】刻意不给 autoplay / muted / loop / controls / playsinline / interactive\n * 设默认值:这些字段同时受 preset 控制,一旦在这里强塞就变成\"显式值\"把 preset 覆盖掉\n *(resolvePreset 的语义是 undefined 才回落到 preset)。没传的 prop 保持 undefined。\n *\n * @example\n * const ref = useRef<VideoPlayerHandle>(null)\n * <VideoPlayer ref={ref} source=\"https://cdn/a.m3u8\" muted onReady={...} />\n * ref.current?.play()\n */\nexport const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(\n function VideoPlayer(props, ref) {\n const { containerRef, handleRef, overlay, mounted, handleRetry, dismissPauseImage } =\n usePlayer(props)\n\n // 命令一律 delegate 给 player-core 句柄。未挂载(SSR / 建之前)时 no-op,消费方不必到处判空。\n // 签名沿用 PlayerCoreHandle 的真实同步 / 异步形态(只有 play 异步)。\n useImperativeHandle(\n ref,\n (): VideoPlayerHandle => ({\n play: () => handleRef.current?.play() ?? Promise.resolve(),\n pause: () => handleRef.current?.pause(),\n seek: (time) => handleRef.current?.seek(time),\n setMuted: (muted) => handleRef.current?.setMuted(muted),\n setVolume: (volume) => handleRef.current?.setVolume(volume),\n setPlaybackRate: (rate) => handleRef.current?.setPlaybackRate(rate),\n enterFullscreen: () => handleRef.current?.enterFullscreen() ?? Promise.resolve(),\n exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),\n setQuality: (level) => handleRef.current?.setQuality(level),\n setSubtitle: (id) => handleRef.current?.setSubtitle(id),\n setLocale: (locale) => handleRef.current?.setLocale(locale),\n pushDanmaku: (item) => handleRef.current?.pushDanmaku(item),\n setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled),\n clearDanmaku: () => handleRef.current?.clearDanmaku(),\n reconnect: (options) => handleRef.current?.reconnect(options),\n destroy: () => handleRef.current?.destroy(),\n getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,\n getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,\n getDuration: () => handleRef.current?.getDuration() ?? 0,\n getPlayerHandle: () => handleRef.current,\n }),\n // handleRef 是稳定 ref,句柄换了不需要重建 imperative handle(方法里实时读 .current)\n [handleRef],\n )\n\n // 只取 messages:`locale` 本身此前只喂给 I18nProvider,现在文案在本层解析完再进元素。\n const { messages } = readLocale(props.locale)\n\n // 封面 / 暂停图都是 `string | { url, fit, … }` 两种形态(protocol 刻意对齐,消费方不学两套)。\n // **不在这里拆包** —— 元素有同名 property(`poster` / `pauseImage`,#120 · PR D),\n // React 19 看到 property 存在就原样传对象,拆包在元素里做。少一处要和契约同步的地方。\n // 这里只判断\"给没给\",决定要不要渲染那个覆盖层。\n const poster = props.poster\n const posterUrl = typeof poster === 'string' ? poster : poster?.url\n const pauseImage = props.pauseImage\n const pauseImageUrl = typeof pauseImage === 'string' ? pauseImage : pauseImage?.url\n\n // 布局样式内联:根节点相对定位,player 填满,覆盖层绝对定位盖在上面(样式来自 player-ui base.css)。\n const style: CSSProperties = {\n position: 'relative',\n width: '100%',\n height: '100%',\n ...(props.interactive === false ? { pointerEvents: 'none' } : {}),\n ...props.style,\n }\n const className = ['sentinel-video', props.className].filter(Boolean).join(' ')\n\n return (\n <div className={className} style={style}>\n <div\n ref={containerRef}\n className=\"sentinel-video__player\"\n style={{ width: '100%', height: '100%' }}\n />\n\n {posterUrl &&\n overlayElement('sentinel-poster', {\n visible: overlay.posterVisible,\n poster,\n })}\n\n {/*\n 暂停图。inline 独有的一条:`<sentinel-pause>` 的默认 slot 表达力更强,\n 但那是消费方在自己进程里的事;`pauseImage` 是**四种接入方式都能用**的契约字段,\n 所以这里也必须渲染 —— 只在 embed-app 里做就漏了 inline(ADR-043)。\n */}\n {pauseImageUrl &&\n overlayElement('sentinel-pause', {\n visible: overlay.pauseImageVisible,\n pauseImage,\n 'close-label': resolveText(messages, 'close'),\n // React 19 原生接住 CustomEvent:小写 `on` + 原样保留连字符(实测过)\n 'onsentinel-close': dismissPauseImage,\n })}\n\n {overlayElement('sentinel-loading', {\n visible: overlay.loadingVisible,\n text: resolveText(messages, 'loading'),\n })}\n\n {overlay.error &&\n overlayElement('sentinel-error', {\n visible: true,\n code: overlay.error.code,\n // 文案取值顺序和原 ErrorOverlay 逐字一致:`messages['error.<CODE>']` → `error.message`。\n // 消费方注了对应错误码的翻译就用翻译,没注就退回 player-core 映射出的原始 message\n // —— 至少是真实信息,而不是把 `error.E_NETWORK` 这样的 key 糊在用户脸上。\n message: resolveErrorText(messages, overlay.error.code, overlay.error.message),\n // 只有 retryable 才给重试按钮:对着必然失败的错误给按钮是骗人,点几次都不会好\n retryable: overlay.error.retryable,\n 'retry-label': resolveText(messages, 'retry'),\n 'onsentinel-retry': handleRetry,\n })}\n\n {/* 挂载前占位(SSR / 建之前):消费方可用 children 覆盖,比如放一张自定义海报 */}\n {!mounted && props.children}\n </div>\n )\n },\n)\n\n/**\n * 错误文案:`messages['error.<CODE>']` → `error.message`。\n *\n * `resolveText` 的语义是「找不到就返回 key」,所以这里要拿返回值和 key 比,\n * 以此判断「消费方没注入这条翻译」—— 和原 `ErrorOverlay` 里那段逐字同构。\n */\nfunction resolveErrorText(\n messages: Parameters<typeof resolveText>[0],\n code: string,\n fallback: string,\n): string {\n const key = `error.${code}`\n const translated = resolveText(messages, key)\n return translated === key ? fallback : translated\n}\n"],"mappings":"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"}
|