@video-lab/react-frame 1.0.1 → 3.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/README.md +54 -11
- package/dist/index.cjs +16 -15
- package/dist/index.d.cts +21 -18
- package/dist/index.d.mts +21 -18
- package/dist/index.mjs +16 -15
- package/package.json +6 -5
- package/skills/host-integration/SKILL.md +72 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @video-lab/react-frame
|
|
2
2
|
|
|
3
|
-
Video Lab Player 的
|
|
3
|
+
Video Lab Player 的 React iframe 接入方式。播放器运行在独立 iframe 中,并通过通信契约收发命令与事件。
|
|
4
4
|
|
|
5
5
|
对外只有一个组件 `<VideoPlayerFrame />` + 一个命令句柄。和 [`@video-lab/vue-frame`](https://www.npmjs.com/package/@video-lab/vue-frame) 行为对齐。
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ function Player() {
|
|
|
33
33
|
return (
|
|
34
34
|
<VideoPlayerFrame
|
|
35
35
|
ref={ref}
|
|
36
|
-
source="https://
|
|
36
|
+
source="https://media.example.com/live/master.m3u8"
|
|
37
37
|
autoplay
|
|
38
38
|
muted
|
|
39
39
|
onReady={({ duration, quality, subtitles }) => {}}
|
|
@@ -55,27 +55,33 @@ ref.current?.seek(30)
|
|
|
55
55
|
await ref.current?.enterFullscreen()
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
###
|
|
58
|
+
### 配置 iframe 地址
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
`origin` 与 `version` 应由内部部署配置提供;本包不声明公共播放器地址。以下值仅为占位符:
|
|
61
61
|
|
|
62
62
|
<!-- doc-snippet-preamble
|
|
63
63
|
import { VideoPlayerFrame } from '@video-lab/react-frame'
|
|
64
64
|
-->
|
|
65
65
|
```tsx
|
|
66
66
|
<VideoPlayerFrame
|
|
67
|
-
origin="https://player.
|
|
68
|
-
version="
|
|
69
|
-
source="https://
|
|
67
|
+
origin="https://player.example.com"
|
|
68
|
+
version="v1"
|
|
69
|
+
source="https://media.example.com/master.m3u8"
|
|
70
70
|
/>
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
## 边界
|
|
74
74
|
|
|
75
|
-
-
|
|
76
|
-
- **props
|
|
77
|
-
-
|
|
78
|
-
-
|
|
75
|
+
- **薄壳**——本包不含播放逻辑,只负责创建 iframe、握手及转发命令与事件。
|
|
76
|
+
- **props 与 `@video-lab/react` 对齐**,另有 iframe 专属的 `origin` / `version`。
|
|
77
|
+
- **不内置品牌 UI;认证只支持签名 URL**,不接受自定义请求头。
|
|
78
|
+
- 业务应用可直接使用本组件,也可在自己的业务组件中封装它。
|
|
79
|
+
|
|
80
|
+
## AI 接入 Skill
|
|
81
|
+
|
|
82
|
+
安装包包含 `skills/host-integration/SKILL.md`。让宿主 AI 使用前,先确认该文件 front matter
|
|
83
|
+
中的 `supportedPackages` 与已安装版本匹配;它提供接入面选择、错误/恢复和 delivered 事件的边界,
|
|
84
|
+
但具体 API 仍以本 README 与类型定义为准。
|
|
79
85
|
|
|
80
86
|
## 相关
|
|
81
87
|
|
|
@@ -83,3 +89,40 @@ import { VideoPlayerFrame } from '@video-lab/react-frame'
|
|
|
83
89
|
- 契约定义:[`@video-lab/protocol`](https://www.npmjs.com/package/@video-lab/protocol)
|
|
84
90
|
|
|
85
91
|
MIT
|
|
92
|
+
|
|
93
|
+
## 完整事件与命令失败
|
|
94
|
+
|
|
95
|
+
`onPlayerEvent` 保持交付 raw `PlayerEvent`;需要跨重连排序、去重或识别迟到事件时使用
|
|
96
|
+
`onDeliveredPlayerEvent`。所有可失败命令都应 `await` 并 `catch`,并同时订阅事件流:命令
|
|
97
|
+
rejection 不保证产生 `error`,播放期 `error` 也不保证对应某个命令 rejection。
|
|
98
|
+
|
|
99
|
+
### iframe 基础路径
|
|
100
|
+
|
|
101
|
+
`origin` 是宿主提供的完整部署基础 URL,可包含 `/custom/player` 等目录。SDK 只追加 `/v{iframe应用版本}/`,不会自动追加 `/embed`。旧部署仍在 `/embed/v…/` 时,请把 `/embed` 显式保留在 `origin` 中;版本目录不要写进 `origin`。
|
|
102
|
+
|
|
103
|
+
## 错误 UI 接管
|
|
104
|
+
|
|
105
|
+
通过 `showErrorOverlay={false}` 关闭默认错误文字、背景和 Retry。省略时保持开启,错误事件与恢复能力不受影响;宿主自行决定提示文案和样式。本项仅初始化读取,改变时须重新挂载。iframe 需使用 v1.1.0 或更高的兼容应用,旧应用可能仍显示默认错误 UI。纯 iframe 标签需另接 helper 才能订阅事件。
|
|
106
|
+
|
|
107
|
+
## 内置控件按需隐藏
|
|
108
|
+
|
|
109
|
+
初始化 prop `controlVisibility`(Vue 模板写 `:control-visibility`)支持 `{ cssFullscreen: false }` 仅隐藏 CSS 全屏按钮。
|
|
110
|
+
八个可选键为 `play`、`progress`、`time`、`volume`、`playbackRate`、`fullscreen`、`cssFullscreen`、`pip`。
|
|
111
|
+
false 隐藏入口,true/省略保留平台默认;`controls=false` 整体关闭优先。隐藏不禁用既有命令,
|
|
112
|
+
运行时修改须由宿主显式重建,不增加 PiP RPC。
|
|
113
|
+
iframe 须使用支持该功能、已部署的精确版本 `/v1.2.0/`;旧 iframe 可能忽略新配置。
|
|
114
|
+
|
|
115
|
+
## 宿主网页全屏
|
|
116
|
+
|
|
117
|
+
通过 `pageFullscreen` 传入宿主布局适配器(`setActive(active)` / `dispose()`),句柄 `setPageFullscreen(active)` 等待布局确认。实际状态事件为 `pagefullscreenchange`;React 使用 `onPageFullscreenChange`,Vue 使用 `@page-fullscreen-change`。
|
|
118
|
+
|
|
119
|
+
详见[网页全屏接入与完整参考实现](../../docs/guides/PAGE-FULLSCREEN.md)。iframe 需要新版宿主与 iframe 协商支持;未启用时新入口不可用。浏览器原生全屏接口保持独立。
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
### 统一恢复(契约 v2)
|
|
123
|
+
|
|
124
|
+
旧 `reconnect({ resetCounter })` 和 `reconnectstart/success/failed` 已移除。命令入口是 `retry(): Promise<void>`;Promise 完成只表示接受或合并请求。恢复中的重复请求共用预算,强播放证据通过 `recovery` 的 `recovered` 回报。
|
|
125
|
+
|
|
126
|
+
宿主 UI 读取 `playablechange` 的 `playable`、`recoverable`、`action`。`error` 只提供诊断;预算耗尽才给出 `action: 'retry'`,宿主无需按错误原因拼接重试状态。使用 `showErrorOverlay={false}` 接管错误样式,使用 `showLoadingOverlay={false}` 接管运行时 Loading;配置在构造时生效,事件仍保留。静态 iframe URL 没有宿主命令通道,内部按钮仍走同一调度。
|
|
127
|
+
|
|
128
|
+
更多迁移语义见 [ADR-099](../../docs/adr/ADR-099-unified-recovery-contract.md)。
|
package/dist/index.cjs
CHANGED
|
@@ -29,12 +29,18 @@ function useFrameConnection(props) {
|
|
|
29
29
|
container,
|
|
30
30
|
source: p.source,
|
|
31
31
|
config: toConfig(p),
|
|
32
|
+
pageFullscreen: p.pageFullscreen,
|
|
32
33
|
...p.origin !== void 0 ? { origin: p.origin } : {},
|
|
33
34
|
...p.version !== void 0 ? { version: p.version } : {},
|
|
34
35
|
onEvent: (event) => {
|
|
35
36
|
if (event.event === "play" || event.event === "error") setHostPosterVisible(false);
|
|
36
37
|
forward(event, propsRef.current);
|
|
37
38
|
},
|
|
39
|
+
onDeliveredEvent: (event) => {
|
|
40
|
+
try {
|
|
41
|
+
propsRef.current.onDeliveredPlayerEvent?.(event);
|
|
42
|
+
} catch {}
|
|
43
|
+
},
|
|
38
44
|
debug: p.debug ?? false
|
|
39
45
|
}).then((h) => {
|
|
40
46
|
if (cancelled) {
|
|
@@ -95,6 +101,9 @@ function toConfig(props) {
|
|
|
95
101
|
if (props.startTime !== void 0) config.startTime = props.startTime;
|
|
96
102
|
if (props.preload !== void 0) config.preload = props.preload;
|
|
97
103
|
if (props.controls !== void 0) config.controls = props.controls;
|
|
104
|
+
if (props.showErrorOverlay !== void 0) config.showErrorOverlay = props.showErrorOverlay;
|
|
105
|
+
if (props.showLoadingOverlay !== void 0) config.showLoadingOverlay = props.showLoadingOverlay;
|
|
106
|
+
if (props.controlVisibility !== void 0) config.controlVisibility = props.controlVisibility;
|
|
98
107
|
if (props.interactive !== void 0) config.interactive = props.interactive;
|
|
99
108
|
if (props.poster !== void 0) config.poster = props.poster;
|
|
100
109
|
if (props.pauseImage !== void 0) config.pauseImage = props.pauseImage;
|
|
@@ -107,9 +116,9 @@ function toConfig(props) {
|
|
|
107
116
|
* iframe 事件 → React 回调。名字从 wire 上的小写拼写换成 `onCamelCase`。
|
|
108
117
|
* 和 vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload 裁剪。
|
|
109
118
|
*
|
|
110
|
-
* **契约声明的
|
|
119
|
+
* **契约声明的 27 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
|
|
111
120
|
* 静态断言。曾经漏过 seeking / seeked / waiting / playing 四个,理由是"团队封装层没用到" ——
|
|
112
|
-
* 但静态 iframe 模式是裸广播、
|
|
121
|
+
* 但静态 iframe 模式是裸广播、27 个全发,于是同一份契约在四种接入方式下能收到的事件
|
|
113
122
|
* 不一样,parity 红线实际已破。别再以"业务暂时用不到"为由少接一个。
|
|
114
123
|
*/
|
|
115
124
|
function forward(event, props) {
|
|
@@ -159,18 +168,6 @@ function forward(event, props) {
|
|
|
159
168
|
case "autoplayblocked":
|
|
160
169
|
props.onAutoplayBlocked?.();
|
|
161
170
|
break;
|
|
162
|
-
case "reconnectstart":
|
|
163
|
-
props.onReconnectStart?.({
|
|
164
|
-
attempt: event.payload.attempt,
|
|
165
|
-
maxAttempts: event.payload.maxAttempts
|
|
166
|
-
});
|
|
167
|
-
break;
|
|
168
|
-
case "reconnectsuccess":
|
|
169
|
-
props.onReconnectSuccess?.();
|
|
170
|
-
break;
|
|
171
|
-
case "reconnectfailed":
|
|
172
|
-
props.onReconnectFailed?.();
|
|
173
|
-
break;
|
|
174
171
|
case "compatwarning":
|
|
175
172
|
props.onCompatWarning?.(event.payload);
|
|
176
173
|
break;
|
|
@@ -195,6 +192,9 @@ function forward(event, props) {
|
|
|
195
192
|
case "framefreeze":
|
|
196
193
|
props.onFrameFreeze?.(event.payload);
|
|
197
194
|
break;
|
|
195
|
+
case "pagefullscreenchange":
|
|
196
|
+
props.onPageFullscreenChange?.(event.payload);
|
|
197
|
+
break;
|
|
198
198
|
case "useraction":
|
|
199
199
|
props.onUserAction?.(event.payload);
|
|
200
200
|
break;
|
|
@@ -263,6 +263,7 @@ const VideoPlayerFrame = (0, react.forwardRef)(function VideoPlayerFrame(props,
|
|
|
263
263
|
setMuted: (muted) => handleRef.current?.setMuted(muted) ?? Promise.resolve(),
|
|
264
264
|
setVolume: (volume) => handleRef.current?.setVolume(volume) ?? Promise.resolve(),
|
|
265
265
|
setPlaybackRate: (rate) => handleRef.current?.setPlaybackRate(rate) ?? Promise.resolve(),
|
|
266
|
+
setPageFullscreen: (active) => handleRef.current?.setPageFullscreen(active) ?? Promise.reject(/* @__PURE__ */ new Error("播放器未就绪")),
|
|
266
267
|
enterFullscreen: () => handleRef.current?.enterFullscreen() ?? Promise.resolve(),
|
|
267
268
|
exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),
|
|
268
269
|
setQuality: (level) => handleRef.current?.setQuality(level) ?? Promise.resolve(),
|
|
@@ -271,7 +272,7 @@ const VideoPlayerFrame = (0, react.forwardRef)(function VideoPlayerFrame(props,
|
|
|
271
272
|
pushDanmaku: (item) => handleRef.current?.pushDanmaku(item) ?? Promise.resolve(),
|
|
272
273
|
setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled) ?? Promise.resolve(),
|
|
273
274
|
clearDanmaku: () => handleRef.current?.clearDanmaku() ?? Promise.resolve(),
|
|
274
|
-
|
|
275
|
+
retry: () => handleRef.current?.retry() ?? Promise.resolve(),
|
|
275
276
|
destroy: () => handleRef.current?.destroy() ?? Promise.resolve(),
|
|
276
277
|
getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,
|
|
277
278
|
getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { AudioHealthPayload, BufferHealthPayload, ControlVisibilityConfig, DanmakuConfig, DanmakuItem, DeliveredPlayerEvent, FirstFramePayload, FrameFreezePayload, KernelHealthPayload, LocaleConfig, MediaSource, PageFullscreenAdapter, PageFullscreenAdapter as PageFullscreenAdapter$1, PageFullscreenState, PageFullscreenState as PageFullscreenState$1, PauseImageConfig, PlaybackContext, PlaybackContextPayload, PlayerError, PlayerEvent, PosterConfig, PresetName, QualityLevel, SourceRouteEventPayload, SubtitleTrackInfo, UserActionPayload } from "@video-lab/protocol";
|
|
1
2
|
import { PlayerFrameHandle } from "@video-lab/frame-core";
|
|
2
|
-
import { AudioHealthPayload, BufferHealthPayload, DanmakuConfig, DanmakuItem, FirstFramePayload, FrameFreezePayload, KernelHealthPayload, LocaleConfig, MediaSource, PauseImageConfig, PlayableReason, PlaybackContext, PlaybackContextPayload, PlayerError, PlayerEvent, PosterConfig, PresetName, QualityLevel, SourceRouteEventPayload, SubtitleTrackInfo, UserActionPayload } from "@video-lab/protocol";
|
|
3
3
|
import { CSSProperties, ReactNode } from "react";
|
|
4
4
|
//#region src/types.d.ts
|
|
5
5
|
/**
|
|
@@ -18,12 +18,20 @@ type PlayerHandle = PlayerFrameHandle;
|
|
|
18
18
|
* protocol 的 PlayerEvent —— 和 vue-frame 的 emits 一一对应(cross-mode-parity)。
|
|
19
19
|
*/
|
|
20
20
|
interface VideoPlayerFrameProps {
|
|
21
|
+
/** 挂载期间保持稳定的宿主网页全屏布局适配器。 */
|
|
22
|
+
pageFullscreen?: PageFullscreenAdapter$1;
|
|
21
23
|
/** 视频源。字符串按 URL 处理,对象可带 type / live / hls 等 */
|
|
22
24
|
source: MediaSource | string;
|
|
23
25
|
autoplay?: boolean;
|
|
24
26
|
muted?: boolean;
|
|
25
27
|
loop?: boolean;
|
|
26
28
|
controls?: boolean;
|
|
29
|
+
/** 默认错误覆盖层,省略时开启。仅初始化读取;变更须重新挂载,不影响错误事件。 */
|
|
30
|
+
showErrorOverlay?: boolean;
|
|
31
|
+
/** 是否显示 SDK 运行时 Loading;构造期读取,默认 true。 */
|
|
32
|
+
showLoadingOverlay?: boolean;
|
|
33
|
+
/** 内置控件初始化显示策略;修改后须由宿主显式重建播放器。 */
|
|
34
|
+
controlVisibility?: ControlVisibilityConfig;
|
|
27
35
|
/** false 时播放器不接收指针事件(点击穿透到下层) */
|
|
28
36
|
interactive?: boolean;
|
|
29
37
|
playsinline?: boolean;
|
|
@@ -57,9 +65,9 @@ interface VideoPlayerFrameProps {
|
|
|
57
65
|
*/
|
|
58
66
|
pauseImage?: string | PauseImageConfig;
|
|
59
67
|
preset?: PresetName;
|
|
60
|
-
/** iframe URL
|
|
68
|
+
/** iframe 部署基础 URL,可包含自定义目录;SDK 只追加版本目录,不追加 embed。 */
|
|
61
69
|
origin?: string;
|
|
62
|
-
/** iframe
|
|
70
|
+
/** iframe 版本目录;默认使用 DEFAULT_VERSION 指定的 iframe 应用精确版本。 */
|
|
63
71
|
version?: string;
|
|
64
72
|
/** 传给 iframe 内 xgplayer 的 i18n 配置 */
|
|
65
73
|
locale?: LocaleConfig;
|
|
@@ -78,6 +86,7 @@ interface VideoPlayerFrameProps {
|
|
|
78
86
|
children?: ReactNode;
|
|
79
87
|
/** 完整的契约事件流,供遥测适配器使用;不裁剪 iframe 转发的 payload。 */
|
|
80
88
|
onPlayerEvent?: (event: PlayerEvent) => void;
|
|
89
|
+
onDeliveredPlayerEvent?: (event: DeliveredPlayerEvent) => void;
|
|
81
90
|
/**
|
|
82
91
|
* metadata 就绪。`quality` 是可切档位清单(单码率源为空数组),团队层据此渲染清晰度菜单;
|
|
83
92
|
* `subtitles` 是可切字幕轨清单(无字幕源为空/缺省,契约 1.0.0 起可选,ADR-027),团队层据此渲染字幕菜单。
|
|
@@ -127,12 +136,6 @@ interface VideoPlayerFrameProps {
|
|
|
127
136
|
}) => void;
|
|
128
137
|
onError?: (payload: PlayerError) => void;
|
|
129
138
|
onAutoplayBlocked?: () => void;
|
|
130
|
-
onReconnectStart?: (payload: {
|
|
131
|
-
attempt: number;
|
|
132
|
-
maxAttempts: number;
|
|
133
|
-
}) => void;
|
|
134
|
-
onReconnectSuccess?: () => void;
|
|
135
|
-
onReconnectFailed?: () => void;
|
|
136
139
|
onCompatWarning?: (payload: {
|
|
137
140
|
code: string;
|
|
138
141
|
message: string;
|
|
@@ -156,11 +159,9 @@ interface VideoPlayerFrameProps {
|
|
|
156
159
|
* `recoverable` 决定盖 loading(等)还是露重试入口(不等);`reason='degraded'`
|
|
157
160
|
* 是唯一 `playable` 仍为 `true` 的值(掉帧严重但画面还在动,可以忽略)。
|
|
158
161
|
*/
|
|
159
|
-
onPlayableChange?: (payload: {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
recoverable: boolean;
|
|
163
|
-
}) => void;
|
|
162
|
+
onPlayableChange?: (payload: Extract<PlayerEvent, {
|
|
163
|
+
event: 'playablechange';
|
|
164
|
+
}>['payload']) => void;
|
|
164
165
|
/**
|
|
165
166
|
* 内核健康 —— **非致命内核诊断的聚合**(契约 4.1.0 / ADR-062 / issue #391)。
|
|
166
167
|
*
|
|
@@ -203,6 +204,8 @@ interface VideoPlayerFrameProps {
|
|
|
203
204
|
* ⚠️ 它不是掉帧率,也不与 `stalled` 重复(后者是缓冲驱动的等待)。
|
|
204
205
|
*/
|
|
205
206
|
onFrameFreeze?: (payload: FrameFreezePayload) => void;
|
|
207
|
+
/** 实际网页全屏状态变化。 */
|
|
208
|
+
onPageFullscreenChange?: (payload: PageFullscreenState$1) => void;
|
|
206
209
|
/**
|
|
207
210
|
* 用户动作(ADR-075),经白名单过滤,只含**发生在 SDK 内部**的那些。
|
|
208
211
|
*
|
|
@@ -225,6 +228,8 @@ interface VideoPlayerFrameHandle {
|
|
|
225
228
|
setMuted(muted: boolean): Promise<void>;
|
|
226
229
|
setVolume(volume: number): Promise<void>;
|
|
227
230
|
setPlaybackRate(rate: number): Promise<void>;
|
|
231
|
+
/** 设置网页全屏;必须等宿主布局与 iframe 状态确认。 */
|
|
232
|
+
setPageFullscreen(active: boolean): Promise<void>;
|
|
228
233
|
/** 进入全屏(容器托管,不碰 video 原生入口,iOS 微信崩溃入口由 FullscreenGuard 中和) */
|
|
229
234
|
enterFullscreen(): Promise<void>;
|
|
230
235
|
/** 退出全屏 */
|
|
@@ -240,9 +245,7 @@ interface VideoPlayerFrameHandle {
|
|
|
240
245
|
setDanmakuEnabled(enabled: boolean): Promise<void>;
|
|
241
246
|
/** 清空屏上弹幕(ADR-028) */
|
|
242
247
|
clearDanmaku(): Promise<void>;
|
|
243
|
-
|
|
244
|
-
resetCounter?: boolean;
|
|
245
|
-
}): Promise<void>;
|
|
248
|
+
retry(): Promise<void>;
|
|
246
249
|
destroy(): Promise<void>;
|
|
247
250
|
getCurrentTime(): number;
|
|
248
251
|
getDuration(): number;
|
|
@@ -280,5 +283,5 @@ interface VideoPlayerFrameHandle {
|
|
|
280
283
|
*/
|
|
281
284
|
declare const VideoPlayerFrame: import("react").ForwardRefExoticComponent<VideoPlayerFrameProps & import("react").RefAttributes<VideoPlayerFrameHandle>>;
|
|
282
285
|
//#endregion
|
|
283
|
-
export { type DanmakuConfig, type DanmakuItem, type MediaSource, type PlayerError, type PlayerHandle, type QualityLevel, type SubtitleTrackInfo, VideoPlayerFrame, type VideoPlayerFrameHandle, type VideoPlayerFrameProps };
|
|
286
|
+
export { type DanmakuConfig, type DanmakuItem, type MediaSource, type PageFullscreenAdapter, type PageFullscreenState, type PlayerError, type PlayerHandle, type QualityLevel, type SubtitleTrackInfo, VideoPlayerFrame, type VideoPlayerFrameHandle, type VideoPlayerFrameProps };
|
|
284
287
|
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CSSProperties, ReactNode } from "react";
|
|
2
2
|
import { PlayerFrameHandle } from "@video-lab/frame-core";
|
|
3
|
-
import { AudioHealthPayload, BufferHealthPayload, DanmakuConfig, DanmakuItem, FirstFramePayload, FrameFreezePayload, KernelHealthPayload, LocaleConfig, MediaSource,
|
|
3
|
+
import { AudioHealthPayload, BufferHealthPayload, ControlVisibilityConfig, DanmakuConfig, DanmakuItem, DeliveredPlayerEvent, FirstFramePayload, FrameFreezePayload, KernelHealthPayload, LocaleConfig, MediaSource, PageFullscreenAdapter, PageFullscreenAdapter as PageFullscreenAdapter$1, PageFullscreenState, PageFullscreenState as PageFullscreenState$1, PauseImageConfig, PlaybackContext, PlaybackContextPayload, PlayerError, PlayerEvent, PosterConfig, PresetName, QualityLevel, SourceRouteEventPayload, SubtitleTrackInfo, UserActionPayload } from "@video-lab/protocol";
|
|
4
4
|
//#region src/types.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* 消费方拿到的播放器句柄(逃生舱)。
|
|
@@ -18,12 +18,20 @@ type PlayerHandle = PlayerFrameHandle;
|
|
|
18
18
|
* protocol 的 PlayerEvent —— 和 vue-frame 的 emits 一一对应(cross-mode-parity)。
|
|
19
19
|
*/
|
|
20
20
|
interface VideoPlayerFrameProps {
|
|
21
|
+
/** 挂载期间保持稳定的宿主网页全屏布局适配器。 */
|
|
22
|
+
pageFullscreen?: PageFullscreenAdapter$1;
|
|
21
23
|
/** 视频源。字符串按 URL 处理,对象可带 type / live / hls 等 */
|
|
22
24
|
source: MediaSource | string;
|
|
23
25
|
autoplay?: boolean;
|
|
24
26
|
muted?: boolean;
|
|
25
27
|
loop?: boolean;
|
|
26
28
|
controls?: boolean;
|
|
29
|
+
/** 默认错误覆盖层,省略时开启。仅初始化读取;变更须重新挂载,不影响错误事件。 */
|
|
30
|
+
showErrorOverlay?: boolean;
|
|
31
|
+
/** 是否显示 SDK 运行时 Loading;构造期读取,默认 true。 */
|
|
32
|
+
showLoadingOverlay?: boolean;
|
|
33
|
+
/** 内置控件初始化显示策略;修改后须由宿主显式重建播放器。 */
|
|
34
|
+
controlVisibility?: ControlVisibilityConfig;
|
|
27
35
|
/** false 时播放器不接收指针事件(点击穿透到下层) */
|
|
28
36
|
interactive?: boolean;
|
|
29
37
|
playsinline?: boolean;
|
|
@@ -57,9 +65,9 @@ interface VideoPlayerFrameProps {
|
|
|
57
65
|
*/
|
|
58
66
|
pauseImage?: string | PauseImageConfig;
|
|
59
67
|
preset?: PresetName;
|
|
60
|
-
/** iframe URL
|
|
68
|
+
/** iframe 部署基础 URL,可包含自定义目录;SDK 只追加版本目录,不追加 embed。 */
|
|
61
69
|
origin?: string;
|
|
62
|
-
/** iframe
|
|
70
|
+
/** iframe 版本目录;默认使用 DEFAULT_VERSION 指定的 iframe 应用精确版本。 */
|
|
63
71
|
version?: string;
|
|
64
72
|
/** 传给 iframe 内 xgplayer 的 i18n 配置 */
|
|
65
73
|
locale?: LocaleConfig;
|
|
@@ -78,6 +86,7 @@ interface VideoPlayerFrameProps {
|
|
|
78
86
|
children?: ReactNode;
|
|
79
87
|
/** 完整的契约事件流,供遥测适配器使用;不裁剪 iframe 转发的 payload。 */
|
|
80
88
|
onPlayerEvent?: (event: PlayerEvent) => void;
|
|
89
|
+
onDeliveredPlayerEvent?: (event: DeliveredPlayerEvent) => void;
|
|
81
90
|
/**
|
|
82
91
|
* metadata 就绪。`quality` 是可切档位清单(单码率源为空数组),团队层据此渲染清晰度菜单;
|
|
83
92
|
* `subtitles` 是可切字幕轨清单(无字幕源为空/缺省,契约 1.0.0 起可选,ADR-027),团队层据此渲染字幕菜单。
|
|
@@ -127,12 +136,6 @@ interface VideoPlayerFrameProps {
|
|
|
127
136
|
}) => void;
|
|
128
137
|
onError?: (payload: PlayerError) => void;
|
|
129
138
|
onAutoplayBlocked?: () => void;
|
|
130
|
-
onReconnectStart?: (payload: {
|
|
131
|
-
attempt: number;
|
|
132
|
-
maxAttempts: number;
|
|
133
|
-
}) => void;
|
|
134
|
-
onReconnectSuccess?: () => void;
|
|
135
|
-
onReconnectFailed?: () => void;
|
|
136
139
|
onCompatWarning?: (payload: {
|
|
137
140
|
code: string;
|
|
138
141
|
message: string;
|
|
@@ -156,11 +159,9 @@ interface VideoPlayerFrameProps {
|
|
|
156
159
|
* `recoverable` 决定盖 loading(等)还是露重试入口(不等);`reason='degraded'`
|
|
157
160
|
* 是唯一 `playable` 仍为 `true` 的值(掉帧严重但画面还在动,可以忽略)。
|
|
158
161
|
*/
|
|
159
|
-
onPlayableChange?: (payload: {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
recoverable: boolean;
|
|
163
|
-
}) => void;
|
|
162
|
+
onPlayableChange?: (payload: Extract<PlayerEvent, {
|
|
163
|
+
event: 'playablechange';
|
|
164
|
+
}>['payload']) => void;
|
|
164
165
|
/**
|
|
165
166
|
* 内核健康 —— **非致命内核诊断的聚合**(契约 4.1.0 / ADR-062 / issue #391)。
|
|
166
167
|
*
|
|
@@ -203,6 +204,8 @@ interface VideoPlayerFrameProps {
|
|
|
203
204
|
* ⚠️ 它不是掉帧率,也不与 `stalled` 重复(后者是缓冲驱动的等待)。
|
|
204
205
|
*/
|
|
205
206
|
onFrameFreeze?: (payload: FrameFreezePayload) => void;
|
|
207
|
+
/** 实际网页全屏状态变化。 */
|
|
208
|
+
onPageFullscreenChange?: (payload: PageFullscreenState$1) => void;
|
|
206
209
|
/**
|
|
207
210
|
* 用户动作(ADR-075),经白名单过滤,只含**发生在 SDK 内部**的那些。
|
|
208
211
|
*
|
|
@@ -225,6 +228,8 @@ interface VideoPlayerFrameHandle {
|
|
|
225
228
|
setMuted(muted: boolean): Promise<void>;
|
|
226
229
|
setVolume(volume: number): Promise<void>;
|
|
227
230
|
setPlaybackRate(rate: number): Promise<void>;
|
|
231
|
+
/** 设置网页全屏;必须等宿主布局与 iframe 状态确认。 */
|
|
232
|
+
setPageFullscreen(active: boolean): Promise<void>;
|
|
228
233
|
/** 进入全屏(容器托管,不碰 video 原生入口,iOS 微信崩溃入口由 FullscreenGuard 中和) */
|
|
229
234
|
enterFullscreen(): Promise<void>;
|
|
230
235
|
/** 退出全屏 */
|
|
@@ -240,9 +245,7 @@ interface VideoPlayerFrameHandle {
|
|
|
240
245
|
setDanmakuEnabled(enabled: boolean): Promise<void>;
|
|
241
246
|
/** 清空屏上弹幕(ADR-028) */
|
|
242
247
|
clearDanmaku(): Promise<void>;
|
|
243
|
-
|
|
244
|
-
resetCounter?: boolean;
|
|
245
|
-
}): Promise<void>;
|
|
248
|
+
retry(): Promise<void>;
|
|
246
249
|
destroy(): Promise<void>;
|
|
247
250
|
getCurrentTime(): number;
|
|
248
251
|
getDuration(): number;
|
|
@@ -280,5 +283,5 @@ interface VideoPlayerFrameHandle {
|
|
|
280
283
|
*/
|
|
281
284
|
declare const VideoPlayerFrame: import("react").ForwardRefExoticComponent<VideoPlayerFrameProps & import("react").RefAttributes<VideoPlayerFrameHandle>>;
|
|
282
285
|
//#endregion
|
|
283
|
-
export { type DanmakuConfig, type DanmakuItem, type MediaSource, type PlayerError, type PlayerHandle, type QualityLevel, type SubtitleTrackInfo, VideoPlayerFrame, type VideoPlayerFrameHandle, type VideoPlayerFrameProps };
|
|
286
|
+
export { type DanmakuConfig, type DanmakuItem, type MediaSource, type PageFullscreenAdapter, type PageFullscreenState, type PlayerError, type PlayerHandle, type QualityLevel, type SubtitleTrackInfo, VideoPlayerFrame, type VideoPlayerFrameHandle, type VideoPlayerFrameProps };
|
|
284
287
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -28,12 +28,18 @@ function useFrameConnection(props) {
|
|
|
28
28
|
container,
|
|
29
29
|
source: p.source,
|
|
30
30
|
config: toConfig(p),
|
|
31
|
+
pageFullscreen: p.pageFullscreen,
|
|
31
32
|
...p.origin !== void 0 ? { origin: p.origin } : {},
|
|
32
33
|
...p.version !== void 0 ? { version: p.version } : {},
|
|
33
34
|
onEvent: (event) => {
|
|
34
35
|
if (event.event === "play" || event.event === "error") setHostPosterVisible(false);
|
|
35
36
|
forward(event, propsRef.current);
|
|
36
37
|
},
|
|
38
|
+
onDeliveredEvent: (event) => {
|
|
39
|
+
try {
|
|
40
|
+
propsRef.current.onDeliveredPlayerEvent?.(event);
|
|
41
|
+
} catch {}
|
|
42
|
+
},
|
|
37
43
|
debug: p.debug ?? false
|
|
38
44
|
}).then((h) => {
|
|
39
45
|
if (cancelled) {
|
|
@@ -94,6 +100,9 @@ function toConfig(props) {
|
|
|
94
100
|
if (props.startTime !== void 0) config.startTime = props.startTime;
|
|
95
101
|
if (props.preload !== void 0) config.preload = props.preload;
|
|
96
102
|
if (props.controls !== void 0) config.controls = props.controls;
|
|
103
|
+
if (props.showErrorOverlay !== void 0) config.showErrorOverlay = props.showErrorOverlay;
|
|
104
|
+
if (props.showLoadingOverlay !== void 0) config.showLoadingOverlay = props.showLoadingOverlay;
|
|
105
|
+
if (props.controlVisibility !== void 0) config.controlVisibility = props.controlVisibility;
|
|
97
106
|
if (props.interactive !== void 0) config.interactive = props.interactive;
|
|
98
107
|
if (props.poster !== void 0) config.poster = props.poster;
|
|
99
108
|
if (props.pauseImage !== void 0) config.pauseImage = props.pauseImage;
|
|
@@ -106,9 +115,9 @@ function toConfig(props) {
|
|
|
106
115
|
* iframe 事件 → React 回调。名字从 wire 上的小写拼写换成 `onCamelCase`。
|
|
107
116
|
* 和 vue-frame 的 forward 一一对应(cross-mode-parity):同样的事件、同样的 payload 裁剪。
|
|
108
117
|
*
|
|
109
|
-
* **契约声明的
|
|
118
|
+
* **契约声明的 27 个事件必须一个不少地出现在这里**,由 `protocol/tests/consumer-surface.contract.test.ts`
|
|
110
119
|
* 静态断言。曾经漏过 seeking / seeked / waiting / playing 四个,理由是"团队封装层没用到" ——
|
|
111
|
-
* 但静态 iframe 模式是裸广播、
|
|
120
|
+
* 但静态 iframe 模式是裸广播、27 个全发,于是同一份契约在四种接入方式下能收到的事件
|
|
112
121
|
* 不一样,parity 红线实际已破。别再以"业务暂时用不到"为由少接一个。
|
|
113
122
|
*/
|
|
114
123
|
function forward(event, props) {
|
|
@@ -158,18 +167,6 @@ function forward(event, props) {
|
|
|
158
167
|
case "autoplayblocked":
|
|
159
168
|
props.onAutoplayBlocked?.();
|
|
160
169
|
break;
|
|
161
|
-
case "reconnectstart":
|
|
162
|
-
props.onReconnectStart?.({
|
|
163
|
-
attempt: event.payload.attempt,
|
|
164
|
-
maxAttempts: event.payload.maxAttempts
|
|
165
|
-
});
|
|
166
|
-
break;
|
|
167
|
-
case "reconnectsuccess":
|
|
168
|
-
props.onReconnectSuccess?.();
|
|
169
|
-
break;
|
|
170
|
-
case "reconnectfailed":
|
|
171
|
-
props.onReconnectFailed?.();
|
|
172
|
-
break;
|
|
173
170
|
case "compatwarning":
|
|
174
171
|
props.onCompatWarning?.(event.payload);
|
|
175
172
|
break;
|
|
@@ -194,6 +191,9 @@ function forward(event, props) {
|
|
|
194
191
|
case "framefreeze":
|
|
195
192
|
props.onFrameFreeze?.(event.payload);
|
|
196
193
|
break;
|
|
194
|
+
case "pagefullscreenchange":
|
|
195
|
+
props.onPageFullscreenChange?.(event.payload);
|
|
196
|
+
break;
|
|
197
197
|
case "useraction":
|
|
198
198
|
props.onUserAction?.(event.payload);
|
|
199
199
|
break;
|
|
@@ -262,6 +262,7 @@ const VideoPlayerFrame = forwardRef(function VideoPlayerFrame(props, ref) {
|
|
|
262
262
|
setMuted: (muted) => handleRef.current?.setMuted(muted) ?? Promise.resolve(),
|
|
263
263
|
setVolume: (volume) => handleRef.current?.setVolume(volume) ?? Promise.resolve(),
|
|
264
264
|
setPlaybackRate: (rate) => handleRef.current?.setPlaybackRate(rate) ?? Promise.resolve(),
|
|
265
|
+
setPageFullscreen: (active) => handleRef.current?.setPageFullscreen(active) ?? Promise.reject(/* @__PURE__ */ new Error("播放器未就绪")),
|
|
265
266
|
enterFullscreen: () => handleRef.current?.enterFullscreen() ?? Promise.resolve(),
|
|
266
267
|
exitFullscreen: () => handleRef.current?.exitFullscreen() ?? Promise.resolve(),
|
|
267
268
|
setQuality: (level) => handleRef.current?.setQuality(level) ?? Promise.resolve(),
|
|
@@ -270,7 +271,7 @@ const VideoPlayerFrame = forwardRef(function VideoPlayerFrame(props, ref) {
|
|
|
270
271
|
pushDanmaku: (item) => handleRef.current?.pushDanmaku(item) ?? Promise.resolve(),
|
|
271
272
|
setDanmakuEnabled: (enabled) => handleRef.current?.setDanmakuEnabled(enabled) ?? Promise.resolve(),
|
|
272
273
|
clearDanmaku: () => handleRef.current?.clearDanmaku() ?? Promise.resolve(),
|
|
273
|
-
|
|
274
|
+
retry: () => handleRef.current?.retry() ?? Promise.resolve(),
|
|
274
275
|
destroy: () => handleRef.current?.destroy() ?? Promise.resolve(),
|
|
275
276
|
getCurrentTime: () => handleRef.current?.getCurrentTime() ?? 0,
|
|
276
277
|
getPlaybackContext: () => handleRef.current?.getPlaybackContext() ?? null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@video-lab/react-frame",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"dist/**/*.d.ts",
|
|
26
26
|
"dist/**/*.d.cts",
|
|
27
27
|
"dist/**/*.d.mts",
|
|
28
|
-
"dist/**/*.css"
|
|
28
|
+
"dist/**/*.css",
|
|
29
|
+
"skills/**"
|
|
29
30
|
],
|
|
30
31
|
"sideEffects": false,
|
|
31
32
|
"peerDependencies": {
|
|
@@ -33,8 +34,8 @@
|
|
|
33
34
|
"react-dom": ">=19.0.0"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@video-lab/protocol": "
|
|
37
|
-
"@video-lab/frame-core": "
|
|
37
|
+
"@video-lab/protocol": "3.0.0",
|
|
38
|
+
"@video-lab/frame-core": "3.0.0"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@testing-library/jest-dom": "^6.0.0",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"access": "public"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
|
-
"build": "tsdown src/index.ts --format esm,cjs --dts --clean",
|
|
57
|
+
"build": "node ../../scripts/sync-public-host-skill.mjs react-frame && tsdown src/index.ts --format esm,cjs --dts --clean",
|
|
57
58
|
"dev": "tsdown src/index.ts --format esm,cjs --dts --watch --no-clean",
|
|
58
59
|
"test": "vitest run",
|
|
59
60
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: video-lab-host-integration
|
|
3
|
+
skillVersion: 1
|
|
4
|
+
sdkMajor: 2
|
|
5
|
+
supportedPackages:
|
|
6
|
+
- '@video-lab/react-frame@2.0.0'
|
|
7
|
+
- '@video-lab/vue-frame@2.0.0'
|
|
8
|
+
description: >-
|
|
9
|
+
在业务宿主项目中接入 Video Lab Player 时使用。选择五种接入面之一,完成可验证 demo,
|
|
10
|
+
并交接 SDK、宿主与服务端责任;不用于修改 SDK 契约或设计品牌 UI。
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Video Lab Player · 宿主接入
|
|
14
|
+
|
|
15
|
+
## 取得与版本绑定
|
|
16
|
+
|
|
17
|
+
在已安装的 `@video-lab/react-frame` 或 `@video-lab/vue-frame` 中读取
|
|
18
|
+
`skills/host-integration/SKILL.md`,再将该目录复制到宿主项目的 AI Skill 目录。先核对
|
|
19
|
+
front matter 的 `supportedPackages` 与安装版本;它是**宿主 AI 的公开接入资料**,不是 npm
|
|
20
|
+
运行时文件,也不是 iframe 部署目录的一部分。
|
|
21
|
+
|
|
22
|
+
API 以已安装包的类型定义和对应 README 为准;本 Skill 只负责选择接入面与交接流程,不能替代类型。
|
|
23
|
+
|
|
24
|
+
## 先选接入面
|
|
25
|
+
|
|
26
|
+
| 宿主条件 | 选择 | 不能承诺 |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| React/Vue 页面,追求性能与深度组合 | `@video-lab/react` / `@video-lab/vue` | 直接操作 xgplayer 私有实例。 |
|
|
29
|
+
| 需要样式、脚本或故障隔离,仍要命令和事件 | `@video-lab/react-frame` / `@video-lab/vue-frame` | 同步读取 iframe DOM 或媒体元素。 |
|
|
30
|
+
| CMS、Markdown、无构建工具,只需嵌入与事件订阅 | 静态 iframe URL,可选 `@video-lab/embed-helper` | 播放命令、换源、字幕、弹幕和实例级握手。 |
|
|
31
|
+
|
|
32
|
+
若静态 iframe 的能力不够,升级到 React/Vue inline 或 iframe。不要私发 `postMessage`、把 JSON 塞进 query,或猜测未在类型中出现的 SDK API。
|
|
33
|
+
|
|
34
|
+
## 实施顺序
|
|
35
|
+
|
|
36
|
+
1. 在宿主项目建立独立 demo 页面,使用服务端提供的完整、已授权 `MediaSource`。不要传 headers、token 刷新回调或业务 ID 给 SDK。
|
|
37
|
+
2. 以公开 `ready`、`PlayerEvent`、错误码和 `playablechange` 驱动业务状态。runtime Loading 只从 `!playable && recoverable` 派生。跨重连 telemetry、排序、去重和迟到事件识别使用 delivered outlet:React iframe 是 `onDeliveredPlayerEvent`,Vue iframe 是 `@delivered-player-event`;旧 iframe 只能提供 raw `PlayerEvent`。
|
|
38
|
+
3. 业务服务负责签名 URL、CORS、权益、字幕资源、弹幕审核与限流;品牌 UI、CTA、菜单和无障碍交互留在宿主。
|
|
39
|
+
4. 业务 telemetry 只记录允许的播放器事实:不要发送完整媒体 URL、query、签名、token、cookie、身份或弹幕正文。
|
|
40
|
+
5. 用真实或受控媒体覆盖首次加载、自动播放拒绝、可恢复/不可恢复错误、签名换源、字幕失败与卸载清理;恢复必须有时间或画面推进证据。
|
|
41
|
+
6. 所有可失败命令都 `await` 并 `catch`;命令 rejection 不保证触发 `error`,播放期 `error` 也不保证对应某个命令 rejection。
|
|
42
|
+
|
|
43
|
+
## 交接模板
|
|
44
|
+
|
|
45
|
+
```md
|
|
46
|
+
### Video Lab 宿主接入结论
|
|
47
|
+
|
|
48
|
+
- 接入面:<React inline / Vue inline / React iframe / Vue iframe / 静态 iframe>
|
|
49
|
+
- 选择原因:<隔离、性能或无构建工具约束>
|
|
50
|
+
- 已验证:<源、事件、Loading、恢复、销毁>
|
|
51
|
+
- SDK 已完成:<实际使用的公开能力>
|
|
52
|
+
- 宿主待完成:<UI、状态、telemetry、无障碍、验收>
|
|
53
|
+
- 服务端/CDN 待完成:<签名 URL、CORS、内容、互动治理>
|
|
54
|
+
- 不支持/风险:<模式限制与明确的产品决定>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 使用资料的优先级
|
|
58
|
+
|
|
59
|
+
1. 已安装 `@video-lab/*` 包的 README 和类型定义是 API 的最终事实。
|
|
60
|
+
2. 接入交接单提供当前的 iframe origin、版本路径、媒体源和部署边界;不得猜测或硬编码内部地址。
|
|
61
|
+
3. 宿主项目的安全、品牌 UI、业务 telemetry 与服务端约束优先于本 Skill 的通用示例。
|
|
62
|
+
|
|
63
|
+
## 宿主要求隐藏单个内置控件
|
|
64
|
+
|
|
65
|
+
使用初始化 `controlVisibility`,例如 `{ cssFullscreen: false }`;静态 iframe 使用 `controlCssFullscreen=0`。
|
|
66
|
+
保留 `controls` 布尔总开关,不覆盖内部 CSS、不访问私有插件。八字段与重建语义见
|
|
67
|
+
[控件显示指南](../../guides/PLAYER-CONTROL-VISIBILITY.md)。先确认安装版本实际包含该字段,
|
|
68
|
+
iframe 同时核实支持此功能的精确应用版本已部署;旧 iframe 可能忽略新字段。
|
|
69
|
+
|
|
70
|
+
## 网页全屏交接
|
|
71
|
+
|
|
72
|
+
iframe CSS 全屏必须由宿主布局。四个框架组件传稳定的 `pageFullscreen` 适配器(`setActive`、`dispose`),静态 URL 用 `createPageFullscreenController` 绑定精确 origin 与 iframe。确认、Esc 和销毁清理由 SDK 编排;宿主负责层级、滚动、焦点恢复,不移动 iframe。未接适配器的新 iframe 隐藏入口。旧 iframe 需配套升级,浏览器原生全屏仍独立。以对应包 README 和 `docs/guides/PAGE-FULLSCREEN.md` 为准。
|