@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Video Lab Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @video-lab/react
|
|
2
|
+
|
|
3
|
+
Video Lab Player 的 **React inline 消费面(L7)**。直连 player-core + player-ui,不走 iframe,首帧最快。
|
|
4
|
+
|
|
5
|
+
对外只有一个组件 `<VideoPlayer />` + 一个命令句柄。见 ADR-001。
|
|
6
|
+
|
|
7
|
+
## 什么时候用它
|
|
8
|
+
|
|
9
|
+
| 场景 | 选这个包 |
|
|
10
|
+
|:---|:---|
|
|
11
|
+
| 追求首帧速度、宿主同为 React | ✅ 本包 |
|
|
12
|
+
| 需要和宿主页面样式/状态深度联动 | ✅ 本包 |
|
|
13
|
+
| 要把播放器和宿主隔离(第三方内容、CSS 冲突) | ❌ 用 [`@video-lab/react-frame`](https://www.npmjs.com/package/@video-lab/react-frame) |
|
|
14
|
+
| 宿主是 Vue | ❌ 用 [`@video-lab/vue`](https://www.npmjs.com/package/@video-lab/vue)(inline,本包的镜像)或 [`@video-lab/vue-frame`](https://www.npmjs.com/package/@video-lab/vue-frame)(iframe) |
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @video-lab/react
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
peer:`react >=19`、`react-dom >=19`。React 18 不支持当前 custom element 覆盖层所需的 property / CustomEvent 绑定;仍在 React 18 的项目请停在上一个 major。
|
|
23
|
+
|
|
24
|
+
## 用法
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { VideoPlayer, type VideoPlayerHandle } from '@video-lab/react'
|
|
28
|
+
// 别漏 —— 少了它播放器照样能播,但控件是散的。引擎样式和覆盖层样式都拼在这一份里,
|
|
29
|
+
// 不需要另外装 xgplayer
|
|
30
|
+
import '@video-lab/react/style.css'
|
|
31
|
+
import { useRef } from 'react'
|
|
32
|
+
|
|
33
|
+
function Player() {
|
|
34
|
+
const ref = useRef<VideoPlayerHandle>(null)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<VideoPlayer
|
|
38
|
+
ref={ref}
|
|
39
|
+
source="https://cdn.example.com/live/master.m3u8"
|
|
40
|
+
autoplay
|
|
41
|
+
muted
|
|
42
|
+
onReady={({ duration, quality, subtitles }) => {
|
|
43
|
+
// quality / subtitles 是可切档位与字幕轨清单,团队层据此渲染菜单
|
|
44
|
+
}}
|
|
45
|
+
onError={(err) => {
|
|
46
|
+
// err.code 是 protocol 注册的 E_* 错误码,err.retryable 指示是否可重试
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
命令走句柄,不走 props:
|
|
54
|
+
|
|
55
|
+
<!-- doc-snippet-preamble
|
|
56
|
+
import type { VideoPlayerHandle } from '@video-lab/react'
|
|
57
|
+
declare const ref: { current: VideoPlayerHandle | null }
|
|
58
|
+
-->
|
|
59
|
+
```tsx
|
|
60
|
+
await ref.current?.play()
|
|
61
|
+
ref.current?.seek(30)
|
|
62
|
+
ref.current?.setQuality('auto')
|
|
63
|
+
ref.current?.setSubtitle(1) // id 来自 onReady 的 subtitles[]
|
|
64
|
+
await ref.current?.enterFullscreen()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 边界
|
|
68
|
+
|
|
69
|
+
- **只做技术**——播放能力、事件、命令、容错。品牌 UI(结束覆盖层、清晰度菜单、皮肤)归团队封装层,不在本包(ADR-021)
|
|
70
|
+
- **props 面刻意和 `react-frame` 对齐**(去掉 iframe 专属的 `origin` / `version`),团队封装层在 inline / iframe 间切换成本最低(cross-mode-parity 测试保障)
|
|
71
|
+
- **认证只支持签名 URL**,不接受自定义请求头(ADR-022)
|
|
72
|
+
- `getPlayerHandle()` 是逃生舱,返回底层 player-core 句柄,给需要绕过 React 直接下命令的高级场景
|
|
73
|
+
|
|
74
|
+
## 相关
|
|
75
|
+
|
|
76
|
+
- 完整 props / 事件 / 句柄清单:[USER-GUIDE](https://mgit.lgroup.co/hqdf/web/x9-live-player/blob/main/docs/guides/USER-GUIDE.md)
|
|
77
|
+
- 契约定义:[`@video-lab/protocol`](https://www.npmjs.com/package/@video-lab/protocol)
|
|
78
|
+
|
|
79
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let react = require("react");
|
|
3
|
+
let _video_lab_player_ui = require("@video-lab/player-ui");
|
|
4
|
+
let _video_lab_protocol = require("@video-lab/protocol");
|
|
5
|
+
let _video_lab_player_core = require("@video-lab/player-core");
|
|
6
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
7
|
+
//#region src/overlay.ts
|
|
8
|
+
/**
|
|
9
|
+
* 从 LocaleConfig 里取翻译表。实现住在 protocol —— iframe 面要做同样的事,
|
|
10
|
+
* 此前两边各有一份逐字相同的副本,是四面行为漂移的经典来源。
|
|
11
|
+
*/
|
|
12
|
+
const readLocale = _video_lab_protocol.resolveLocaleMessages;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/overlay-elements.tsx
|
|
15
|
+
/**
|
|
16
|
+
* `createElement` 要求 tag 是 `keyof JSX.IntrinsicElements`,而 `@types/react@19`
|
|
17
|
+
* **仍然不认识** custom element(实测:`Property 'sentinel-poster' does not exist on type
|
|
18
|
+
* 'JSX.IntrinsicElements'`)。
|
|
19
|
+
*
|
|
20
|
+
* **不做全局 JSX 命名空间增补** —— 那会污染所有消费方的类型环境,库不该干这事
|
|
21
|
+
* (对照:media-chrome 也不增补,它另外发一套 `/react` 包装)。
|
|
22
|
+
* 逃逸收敛在这一行,外面全是有类型的。
|
|
23
|
+
*/
|
|
24
|
+
const createOverlayElement = react.createElement;
|
|
25
|
+
/**
|
|
26
|
+
* 渲染一个覆盖层元素。
|
|
27
|
+
*
|
|
28
|
+
* 这不是"包装组件",只是一个带类型的 `createElement` 调用 + 一次幂等注册 ——
|
|
29
|
+
* React 19 之后没有别的事要做了。
|
|
30
|
+
*/
|
|
31
|
+
function overlayElement(tag, props) {
|
|
32
|
+
(0, _video_lab_player_ui.defineSentinelOverlays)();
|
|
33
|
+
const { children, ...rest } = props;
|
|
34
|
+
return createOverlayElement(tag, rest, children);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 取文案。**语义和原 `useT()` 逐字一致:key 找不到就返回 key 本身。**
|
|
38
|
+
*
|
|
39
|
+
* 不 throw 也不返回空串 —— 漏翻译时界面上会明晃晃出现 `error.E_NETWORK` 这种字样,一眼能发现;
|
|
40
|
+
* throw 会白屏,空串是静默失败,最难排查。
|
|
41
|
+
*
|
|
42
|
+
* i18n 解析在 PR B 里上移到了这一层(spec § 技术方案 ②):元素只收解析好的字符串,
|
|
43
|
+
* 于是 player-ui 不再需要任何状态注入机制。
|
|
44
|
+
*/
|
|
45
|
+
function resolveText(messages, key) {
|
|
46
|
+
return messages[key] ?? key;
|
|
47
|
+
}
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/use-player.ts
|
|
50
|
+
/**
|
|
51
|
+
* 管理 player-core 的整个生命周期,并把播放器事件同时喂给覆盖层状态机和 React 回调 props。
|
|
52
|
+
*
|
|
53
|
+
* 和 react-frame 的 `useFrameConnection` 结构对齐,差别在于 inline 直连 player-core:
|
|
54
|
+
* - `createPlayer` 是**同步**的(不像 `createPlayerFrame` 返回 Promise),但会**同步抛错**
|
|
55
|
+
* (选源失败,比如 iOS 只给了 FLV)——必须 try/catch 转成 error 覆盖层 + onError。
|
|
56
|
+
* - 覆盖层(Poster / Loading / Error)由本包渲染,所以事件要先更新 overlay 再转发回调
|
|
57
|
+
* (iframe 系的覆盖层在 embed-app 内,薄壳不管)。
|
|
58
|
+
*
|
|
59
|
+
* **事件桥接关键**:onEvent 闭包只在挂载时建一次,但要调**最新**回调 props——用 `propsRef`
|
|
60
|
+
* 存最新 props,`forward` 从 `propsRef.current` 取,而不是闭包里捕获的旧值。
|
|
61
|
+
* SSR 安全:`createPlayer` 只在 useEffect(客户端)里跑。
|
|
62
|
+
*/
|
|
63
|
+
function usePlayer(props) {
|
|
64
|
+
const containerRef = (0, react.useRef)(null);
|
|
65
|
+
const handleRef = (0, react.useRef)(null);
|
|
66
|
+
const [overlay, setOverlay] = (0, react.useState)(_video_lab_player_ui.INITIAL_OVERLAY);
|
|
67
|
+
const [mounted, setMounted] = (0, react.useState)(false);
|
|
68
|
+
const [rebuildKey, setRebuildKey] = (0, react.useState)(0);
|
|
69
|
+
const propsRef = (0, react.useRef)(props);
|
|
70
|
+
propsRef.current = props;
|
|
71
|
+
/**
|
|
72
|
+
* 三条 prop effect 的 skip-first-run 标志位。
|
|
73
|
+
*
|
|
74
|
+
* **必须在 build effect 的 cleanup 里复位**(#720):StrictMode dev 下 React 对整棵子树
|
|
75
|
+
* 做 setup → cleanup → setup,而这三个 ref **此前没有任何复位点**。第二遍 setup 时
|
|
76
|
+
* build effect 先重建出一个新 player,紧接着这三条 effect 看到标志位已是 `true`,
|
|
77
|
+
* 于是对一个**刚建好、config 里已带同样值**的播放器再下发一次
|
|
78
|
+
* `load()` / `setMuted()` / `setLocale()`。
|
|
79
|
+
*
|
|
80
|
+
* 后果不只是「画面闪、缓冲白扔」—— `load()` 成功会开**新 session**(ADR-074),
|
|
81
|
+
* dev 下每次挂载都在遥测里多出一段幽灵会话,`contextchange` 的 `sessionId` 对不上。
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ **Vue 无此形态**(`watch` 默认不 immediate),**react-frame 也免疫**
|
|
84
|
+
* (`createPlayerFrame` 异步,第二遍 setup 时句柄仍是 null)。
|
|
85
|
+
* inline 因为 `createPlayer` 是**同步**的才需要这三个标志位,也因此才有这个洞。
|
|
86
|
+
*/
|
|
87
|
+
const sourceInited = (0, react.useRef)(false);
|
|
88
|
+
const mutedInited = (0, react.useRef)(false);
|
|
89
|
+
const localeInited = (0, react.useRef)(false);
|
|
90
|
+
/**
|
|
91
|
+
* 错误出口。**建 player 失败(同步抛)和换源失败(异步 reject)必须走同一条路**:
|
|
92
|
+
* 错误覆盖层 + `onError`。分成两条会出现"换源失败时组件毫无反应"的静默失败。
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ **必须走 `forward()`,不能直接调 `onError`。** 这里曾经是
|
|
95
|
+
* `propsRef.current.onError?.(payload)` —— 于是这条 error **进不了 `onPlayerEvent`**,
|
|
96
|
+
* 而两个 iframe 面的同一条失败路径走的就是 `forward(...)`(见
|
|
97
|
+
* `react-frame/src/use-frame-connection.ts` 的 `.catch(err => forward(...))`)。
|
|
98
|
+
*
|
|
99
|
+
* 后果是 parity 破口:消费方按 ADR-080 接 `onPlayerEvent` 做遥测时,
|
|
100
|
+
* **iframe 两面收得到这条 error,inline 两面一条都收不到** —— 而 `types.ts` 对那个 prop
|
|
101
|
+
* 的原话是「完整的契约事件流,供遥测适配器使用;不裁剪 payload」。
|
|
102
|
+
*
|
|
103
|
+
* 命中的两条真实路径:`createPlayer` 同步抛(iOS 只给 FLV)、ADR-083 的构造期签名拒绝。
|
|
104
|
+
*/
|
|
105
|
+
const reportError = (0, react.useCallback)((err) => {
|
|
106
|
+
const errorEvent = (0, _video_lab_protocol.toErrorEvent)(err);
|
|
107
|
+
setOverlay((prev) => (0, _video_lab_player_ui.reduceOverlay)(prev, errorEvent));
|
|
108
|
+
forward(errorEvent, propsRef.current);
|
|
109
|
+
}, []);
|
|
110
|
+
/**
|
|
111
|
+
* **prop 驱动**的命令统一出口(ADR-067 · #416)。
|
|
112
|
+
*
|
|
113
|
+
* 只多做一件事:句柄**已销毁**就报错、不下发 —— 因为 `create-player.ts` 里
|
|
114
|
+
* 每个命令开头的 `if (destroyed) return` 会让它**静默空转**。那个 `return` 是对的
|
|
115
|
+
*(坑 #3:destroy 之后不许再有事件出去),错的是上一层从来不说话:实测消费方
|
|
116
|
+
* 调完 `destroy()` 再改 `source`,inline 面 **0 条事件**,而 iframe 面报了一条。
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ **句柄为 `null` 时静默是对的**,别顺手也报 —— `null` 意味着「还没建成 / 已卸载」,
|
|
119
|
+
* 不是「被拆了」(ADR-066 事实 ③:卸载先 `destroy()` 再置 `null`)。
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ **三条 prop effect 都要走它**,别只管 `load`。只 gate 一条会造出新的 parity 差异:
|
|
122
|
+
* `setMuted` 在 iframe 有声、在 inline 静默 —— 用一个洞换另一个洞。
|
|
123
|
+
*/
|
|
124
|
+
const dispatch = (0, react.useCallback)((run) => {
|
|
125
|
+
const handle = handleRef.current;
|
|
126
|
+
if (!handle) return;
|
|
127
|
+
if (handle.destroyed) {
|
|
128
|
+
reportError({ playerError: (0, _video_lab_protocol.makePlayerError)("E_PLAYER_DESTROYED", _video_lab_protocol.PLAYER_DESTROYED_MESSAGE) });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
run(handle);
|
|
132
|
+
}, [reportError]);
|
|
133
|
+
(0, react.useEffect)(() => {
|
|
134
|
+
const container = containerRef.current;
|
|
135
|
+
if (!container) return;
|
|
136
|
+
const p = propsRef.current;
|
|
137
|
+
setOverlay(_video_lab_player_ui.INITIAL_OVERLAY);
|
|
138
|
+
try {
|
|
139
|
+
handleRef.current = (0, _video_lab_player_core.createPlayer)({
|
|
140
|
+
el: container,
|
|
141
|
+
config: toConfig(p),
|
|
142
|
+
onEvent: (event) => {
|
|
143
|
+
setOverlay((prev) => (0, _video_lab_player_ui.reduceOverlay)(prev, event));
|
|
144
|
+
forward(event, propsRef.current);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
setMounted(true);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
reportError(err);
|
|
150
|
+
}
|
|
151
|
+
return () => {
|
|
152
|
+
handleRef.current?.destroy();
|
|
153
|
+
handleRef.current = null;
|
|
154
|
+
setMounted(false);
|
|
155
|
+
sourceInited.current = false;
|
|
156
|
+
mutedInited.current = false;
|
|
157
|
+
localeInited.current = false;
|
|
158
|
+
};
|
|
159
|
+
}, [rebuildKey]);
|
|
160
|
+
(0, react.useEffect)(() => {
|
|
161
|
+
if (!sourceInited.current) {
|
|
162
|
+
sourceInited.current = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setOverlay(_video_lab_player_ui.resetOverlayForNewSource);
|
|
166
|
+
dispatch((handle) => {
|
|
167
|
+
handle.load(props.source).catch(reportError);
|
|
168
|
+
});
|
|
169
|
+
}, [
|
|
170
|
+
JSON.stringify(props.source),
|
|
171
|
+
reportError,
|
|
172
|
+
dispatch
|
|
173
|
+
]);
|
|
174
|
+
(0, react.useEffect)(() => {
|
|
175
|
+
if (!mutedInited.current) {
|
|
176
|
+
mutedInited.current = true;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const muted = props.muted;
|
|
180
|
+
if (muted !== void 0) dispatch((handle) => handle.setMuted(muted));
|
|
181
|
+
}, [props.muted, dispatch]);
|
|
182
|
+
(0, react.useEffect)(() => {
|
|
183
|
+
if (!localeInited.current) {
|
|
184
|
+
localeInited.current = true;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const locale = props.locale;
|
|
188
|
+
if (locale !== void 0) dispatch((handle) => handle.setLocale(locale));
|
|
189
|
+
}, [props.locale, dispatch]);
|
|
190
|
+
return {
|
|
191
|
+
containerRef,
|
|
192
|
+
handleRef,
|
|
193
|
+
overlay,
|
|
194
|
+
mounted,
|
|
195
|
+
handleRetry: (0, react.useCallback)(() => {
|
|
196
|
+
setRebuildKey((k) => k + 1);
|
|
197
|
+
}, []),
|
|
198
|
+
dismissPauseImage: (0, react.useCallback)(() => {
|
|
199
|
+
setOverlay((prev) => ({
|
|
200
|
+
...prev,
|
|
201
|
+
pauseImageVisible: false,
|
|
202
|
+
pauseImageDismissed: true
|
|
203
|
+
}));
|
|
204
|
+
}, [])
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* props → PlayerConfig。只带"显式传了的"字段;undefined 交给 player-core 走 preset / xgplayer 默认
|
|
209
|
+
*(resolvePreset 的语义就是 undefined = 没传)。source 直接进 config(createPlayer 的 config 含 source)。
|
|
210
|
+
*/
|
|
211
|
+
function toConfig(props) {
|
|
212
|
+
const config = { source: props.source };
|
|
213
|
+
if (props.preset !== void 0) config.preset = props.preset;
|
|
214
|
+
if (props.autoplay !== void 0) config.autoplay = props.autoplay;
|
|
215
|
+
if (props.muted !== void 0) config.muted = props.muted;
|
|
216
|
+
if (props.loop !== void 0) config.loop = props.loop;
|
|
217
|
+
if (props.playsinline !== void 0) config.playsinline = props.playsinline;
|
|
218
|
+
if (props.playbackRate !== void 0) config.playbackRate = props.playbackRate;
|
|
219
|
+
if (props.volume !== void 0) config.volume = props.volume;
|
|
220
|
+
if (props.startTime !== void 0) config.startTime = props.startTime;
|
|
221
|
+
if (props.preload !== void 0) config.preload = props.preload;
|
|
222
|
+
if (props.controls !== void 0) config.controls = props.controls;
|
|
223
|
+
if (props.interactive !== void 0) config.interactive = props.interactive;
|
|
224
|
+
if (props.poster !== void 0) config.poster = props.poster;
|
|
225
|
+
if (props.pauseImage !== void 0) config.pauseImage = props.pauseImage;
|
|
226
|
+
if (props.locale !== void 0) config.locale = props.locale;
|
|
227
|
+
if (props.danmaku !== void 0) config.danmaku = props.danmaku;
|
|
228
|
+
if (props.debug !== void 0) config.debug = props.debug;
|
|
229
|
+
return config;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 播放器事件 → React 回调。名字从 wire 上的小写换成 `onCamelCase`。
|
|
233
|
+
* 和 react-frame / vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload。
|
|
234
|
+
*
|
|
235
|
+
* **契约声明的 19 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
|
|
236
|
+
* 静态断言。曾经漏过 4 个(seeking / seeked / waiting / playing):前两个有注释说
|
|
237
|
+
* 「团队封装层没用到,需要时再加」,后两个连注释都没有 —— 被 `default: break` 静默吞掉。
|
|
238
|
+
* 而静态 iframe 模式(embed-app 裸广播,无过滤)19 个全发,于是同一份契约在四种接入方式
|
|
239
|
+
* 下能收到的事件不一样,parity 红线实际已破。别再以"业务暂时用不到"为由少接一个。
|
|
240
|
+
*/
|
|
241
|
+
function forward(event, props) {
|
|
242
|
+
try {
|
|
243
|
+
props.onPlayerEvent?.(event);
|
|
244
|
+
} catch {}
|
|
245
|
+
switch (event.event) {
|
|
246
|
+
case "ready":
|
|
247
|
+
props.onReady?.(event.payload);
|
|
248
|
+
break;
|
|
249
|
+
case "play":
|
|
250
|
+
props.onPlay?.();
|
|
251
|
+
break;
|
|
252
|
+
case "pause":
|
|
253
|
+
props.onPause?.();
|
|
254
|
+
break;
|
|
255
|
+
case "ended":
|
|
256
|
+
props.onEnded?.();
|
|
257
|
+
break;
|
|
258
|
+
case "timeupdate":
|
|
259
|
+
props.onTimeUpdate?.(event.payload);
|
|
260
|
+
break;
|
|
261
|
+
case "volumechange":
|
|
262
|
+
props.onVolumeChange?.(event.payload);
|
|
263
|
+
break;
|
|
264
|
+
case "seeking":
|
|
265
|
+
props.onSeeking?.(event.payload);
|
|
266
|
+
break;
|
|
267
|
+
case "seeked":
|
|
268
|
+
props.onSeeked?.(event.payload);
|
|
269
|
+
break;
|
|
270
|
+
case "waiting":
|
|
271
|
+
props.onWaiting?.();
|
|
272
|
+
break;
|
|
273
|
+
case "playing":
|
|
274
|
+
props.onPlaying?.();
|
|
275
|
+
break;
|
|
276
|
+
case "qualitychange":
|
|
277
|
+
props.onQualityChange?.(event.payload);
|
|
278
|
+
break;
|
|
279
|
+
case "subtitlechange":
|
|
280
|
+
props.onSubtitleChange?.(event.payload);
|
|
281
|
+
break;
|
|
282
|
+
case "error":
|
|
283
|
+
props.onError?.(event.payload);
|
|
284
|
+
break;
|
|
285
|
+
case "autoplayblocked":
|
|
286
|
+
props.onAutoplayBlocked?.();
|
|
287
|
+
break;
|
|
288
|
+
case "reconnectstart":
|
|
289
|
+
props.onReconnectStart?.({
|
|
290
|
+
attempt: event.payload.attempt,
|
|
291
|
+
maxAttempts: event.payload.maxAttempts
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
case "reconnectsuccess":
|
|
295
|
+
props.onReconnectSuccess?.();
|
|
296
|
+
break;
|
|
297
|
+
case "reconnectfailed":
|
|
298
|
+
props.onReconnectFailed?.();
|
|
299
|
+
break;
|
|
300
|
+
case "compatwarning":
|
|
301
|
+
props.onCompatWarning?.(event.payload);
|
|
302
|
+
break;
|
|
303
|
+
case "stalled":
|
|
304
|
+
props.onStalled?.(event.payload);
|
|
305
|
+
break;
|
|
306
|
+
case "playablechange":
|
|
307
|
+
props.onPlayableChange?.(event.payload);
|
|
308
|
+
break;
|
|
309
|
+
case "bufferhealth":
|
|
310
|
+
props.onBufferHealth?.(event.payload);
|
|
311
|
+
break;
|
|
312
|
+
case "contextchange":
|
|
313
|
+
props.onContextChange?.(event.payload);
|
|
314
|
+
break;
|
|
315
|
+
case "sourceroute":
|
|
316
|
+
props.onSourceRoute?.(event.payload);
|
|
317
|
+
break;
|
|
318
|
+
case "firstframe":
|
|
319
|
+
props.onFirstFrame?.(event.payload);
|
|
320
|
+
break;
|
|
321
|
+
case "framefreeze":
|
|
322
|
+
props.onFrameFreeze?.(event.payload);
|
|
323
|
+
break;
|
|
324
|
+
case "useraction":
|
|
325
|
+
props.onUserAction?.(event.payload);
|
|
326
|
+
break;
|
|
327
|
+
case "kernelhealth":
|
|
328
|
+
props.onKernelHealth?.(event.payload);
|
|
329
|
+
break;
|
|
330
|
+
case "audiohealth":
|
|
331
|
+
props.onAudioHealth?.(event.payload);
|
|
332
|
+
break;
|
|
333
|
+
case "recovery": break;
|
|
334
|
+
default: break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/VideoPlayer.tsx
|
|
339
|
+
/**
|
|
340
|
+
* VideoPlayer · React inline 高性能模式(SDK 消费面之一)
|
|
341
|
+
*
|
|
342
|
+
* 直连 player-core + player-ui,不走 iframe——首帧最快(~200ms),数据路径和 iframe 系不同。
|
|
343
|
+
*
|
|
344
|
+
* **覆盖层用 player-ui 的 `<sentinel-*>` custom elements**(#120 · R1 · PR B),不再是 React 组件。
|
|
345
|
+
* 消费方 API 一个都没变 —— props / 事件 / 句柄全部原样。变的只有两件内部事:
|
|
346
|
+
* ① i18n 解析上移到这一层(元素只收解析好的字符串,player-ui 不再需要状态注入机制);
|
|
347
|
+
* ② 覆盖层的样式钩子从全局 class 变成 shadow DOM 的 `::part()`(见 USER-GUIDE)。
|
|
348
|
+
* 只做技术:组装播放器、把 props 桥接成命令、把事件桥接成 `onXxx` 回调、渲染 SDK 自带的
|
|
349
|
+
* Poster / Loading / Error 覆盖层。不含任何团队品牌 UI(ADR-021)——那些在业务封装层。
|
|
350
|
+
* props / 事件面和 react-frame 对齐(cross-mode-parity)。
|
|
351
|
+
*
|
|
352
|
+
* 【关于默认值】刻意不给 autoplay / muted / loop / controls / playsinline / interactive
|
|
353
|
+
* 设默认值:这些字段同时受 preset 控制,一旦在这里强塞就变成"显式值"把 preset 覆盖掉
|
|
354
|
+
*(resolvePreset 的语义是 undefined 才回落到 preset)。没传的 prop 保持 undefined。
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* const ref = useRef<VideoPlayerHandle>(null)
|
|
358
|
+
* <VideoPlayer ref={ref} source="https://cdn/a.m3u8" muted onReady={...} />
|
|
359
|
+
* ref.current?.play()
|
|
360
|
+
*/
|
|
361
|
+
const VideoPlayer = (0, react.forwardRef)(function VideoPlayer(props, ref) {
|
|
362
|
+
const { containerRef, handleRef, overlay, mounted, handleRetry, dismissPauseImage } = usePlayer(props);
|
|
363
|
+
(0, react.useImperativeHandle)(ref, () => ({
|
|
364
|
+
play: () => handleRef.current?.play() ?? Promise.resolve(),
|
|
365
|
+
pause: () => handleRef.current?.pause(),
|
|
366
|
+
seek: (time) => handleRef.current?.seek(time),
|
|
367
|
+
setMuted: (muted) => handleRef.current?.setMuted(muted),
|
|
368
|
+
setVolume: (volume) => handleRef.current?.setVolume(volume),
|
|
369
|
+
setPlaybackRate: (rate) => handleRef.current?.setPlaybackRate(rate),
|
|
370
|
+
enterFullscreen: () => handleRef.current?.enterFullscreen() ?? Promise.resolve(),
|
|
371
|
+
exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),
|
|
372
|
+
setQuality: (level) => handleRef.current?.setQuality(level),
|
|
373
|
+
setSubtitle: (id) => handleRef.current?.setSubtitle(id),
|
|
374
|
+
setLocale: (locale) => handleRef.current?.setLocale(locale),
|
|
375
|
+
pushDanmaku: (item) => handleRef.current?.pushDanmaku(item),
|
|
376
|
+
setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled),
|
|
377
|
+
clearDanmaku: () => handleRef.current?.clearDanmaku(),
|
|
378
|
+
reconnect: (options) => handleRef.current?.reconnect(options),
|
|
379
|
+
destroy: () => handleRef.current?.destroy(),
|
|
380
|
+
getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,
|
|
381
|
+
getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,
|
|
382
|
+
getDuration: () => handleRef.current?.getDuration() ?? 0,
|
|
383
|
+
getPlayerHandle: () => handleRef.current
|
|
384
|
+
}), [handleRef]);
|
|
385
|
+
const { messages } = readLocale(props.locale);
|
|
386
|
+
const poster = props.poster;
|
|
387
|
+
const posterUrl = typeof poster === "string" ? poster : poster?.url;
|
|
388
|
+
const pauseImage = props.pauseImage;
|
|
389
|
+
const pauseImageUrl = typeof pauseImage === "string" ? pauseImage : pauseImage?.url;
|
|
390
|
+
const style = {
|
|
391
|
+
position: "relative",
|
|
392
|
+
width: "100%",
|
|
393
|
+
height: "100%",
|
|
394
|
+
...props.interactive === false ? { pointerEvents: "none" } : {},
|
|
395
|
+
...props.style
|
|
396
|
+
};
|
|
397
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
398
|
+
className: ["sentinel-video", props.className].filter(Boolean).join(" "),
|
|
399
|
+
style,
|
|
400
|
+
children: [
|
|
401
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
402
|
+
ref: containerRef,
|
|
403
|
+
className: "sentinel-video__player",
|
|
404
|
+
style: {
|
|
405
|
+
width: "100%",
|
|
406
|
+
height: "100%"
|
|
407
|
+
}
|
|
408
|
+
}),
|
|
409
|
+
posterUrl && overlayElement("sentinel-poster", {
|
|
410
|
+
visible: overlay.posterVisible,
|
|
411
|
+
poster
|
|
412
|
+
}),
|
|
413
|
+
pauseImageUrl && overlayElement("sentinel-pause", {
|
|
414
|
+
visible: overlay.pauseImageVisible,
|
|
415
|
+
pauseImage,
|
|
416
|
+
"close-label": resolveText(messages, "close"),
|
|
417
|
+
"onsentinel-close": dismissPauseImage
|
|
418
|
+
}),
|
|
419
|
+
overlayElement("sentinel-loading", {
|
|
420
|
+
visible: overlay.loadingVisible,
|
|
421
|
+
text: resolveText(messages, "loading")
|
|
422
|
+
}),
|
|
423
|
+
overlay.error && overlayElement("sentinel-error", {
|
|
424
|
+
visible: true,
|
|
425
|
+
code: overlay.error.code,
|
|
426
|
+
message: resolveErrorText(messages, overlay.error.code, overlay.error.message),
|
|
427
|
+
retryable: overlay.error.retryable,
|
|
428
|
+
"retry-label": resolveText(messages, "retry"),
|
|
429
|
+
"onsentinel-retry": handleRetry
|
|
430
|
+
}),
|
|
431
|
+
!mounted && props.children
|
|
432
|
+
]
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
/**
|
|
436
|
+
* 错误文案:`messages['error.<CODE>']` → `error.message`。
|
|
437
|
+
*
|
|
438
|
+
* `resolveText` 的语义是「找不到就返回 key」,所以这里要拿返回值和 key 比,
|
|
439
|
+
* 以此判断「消费方没注入这条翻译」—— 和原 `ErrorOverlay` 里那段逐字同构。
|
|
440
|
+
*/
|
|
441
|
+
function resolveErrorText(messages, code, fallback) {
|
|
442
|
+
const key = `error.${code}`;
|
|
443
|
+
const translated = resolveText(messages, key);
|
|
444
|
+
return translated === key ? fallback : translated;
|
|
445
|
+
}
|
|
446
|
+
//#endregion
|
|
447
|
+
exports.VideoPlayer = VideoPlayer;
|