@sentinel-lab/video-player-ui
v4.0.0
Published
Sentinel Video Player 覆盖层:Poster / Loading / Error / Pause 四个框架无关的 custom element + 覆盖层状态机
Readme
@sentinel-lab/video-player-ui
Sentinel Video Player 的覆盖层(L2)· 框架无关。四个 custom element,零框架依赖 ——
inline(video-react)和 iframe 内部(apps/embed-app)用的是同一份。
依赖:只有 @sentinel-lab/video-protocol。没有 peer 依赖,不依赖 React,也不依赖 Vue。
实现范围
SDK 侧 4 个覆盖层全部实现:<sentinel-poster>(封面)、<sentinel-loading>(缓冲)、
<sentinel-error>(错误 + 重试)、<sentinel-pause>(暂停时的中性广告 / 推荐插槽,可选关闭按钮)。
结束覆盖层 + 倒计时(End / Countdown)按 ADR-025 归团队层(examples/team-video-vue 用 ended 事件
自己渲染、自跑 setInterval 倒计时),刻意不在本包 —— 业务调度的覆盖层归团队层,SDK 只做
事件驱动、无需业务内容的自动覆盖层(Poster/Loading/Error/Pause)。
用法
import {
defineSentinelOverlays,
INITIAL_OVERLAY,
type OverlayState,
reduceOverlay,
} from '@sentinel-lab/video-player-ui'
import type { PlayerEvent } from '@sentinel-lab/video-protocol'
// ① 注册。**必须显式调**,不会自动注册(理由见下)
defineSentinelOverlays()
const container = document.querySelector('.player-container') as HTMLElement
const poster = document.createElement('sentinel-poster')
const error = document.createElement('sentinel-error')
container.append(poster, error)
// ② 播放器事件 → 覆盖层状态 → 元素属性。
// reduceOverlay 是纯函数,inline / iframe 两面共用同一份,免得"什么时候盖 loading"漂移
let state: OverlayState = INITIAL_OVERLAY
export function onPlayerEvent(event: PlayerEvent): void {
const next = reduceOverlay(state, event)
if (next === state) return // 引用没变 = 这个事件不影响覆盖层,别白刷 DOM
state = next
poster.toggleAttribute('visible', state.posterVisible)
error.toggleAttribute('visible', state.error !== null)
if (state.error) {
error.setAttribute('code', state.error.code)
error.setAttribute('message', state.error.message)
error.toggleAttribute('retryable', state.error.retryable)
error.setAttribute('retry-label', '重试') // 不传就退回英文 'Retry'
}
}
// ③ 重试是 CustomEvent(冒泡),不是回调 prop
error.addEventListener('sentinel-retry', () => {
/* 重建播放器 */
})API
元素(标签名 → 导出的构造器,后者只在做类型标注 / 手动注册时才需要)
| 标签 | 构造器 | 默认 z-index | 专有 attribute | part |
|---|---|---|---|---|
| <sentinel-poster> | SentinelPosterElement | 10 | src fit loading alt | image |
| <sentinel-loading> | SentinelLoadingElement | 12 | text show-text | spinner text |
| <sentinel-pause> | SentinelPauseElement | 13 | closable close-label image image-fit | close-button image |
| <sentinel-error> | SentinelErrorElement | 15 | code message retryable retry-label | box message retry-button |
四个共有:attribute visible / z-index,property visible(boolean),
元素上会被打上 data-sentinel-overlay="<名字>"(测试选择器约定)。
<sentinel-poster> 和 <sentinel-pause> 另有 property 通道 poster / pauseImage
收契约对象(PosterConfig / PauseImageConfig)—— 实现是写进 attribute 而不是另存一份,
所以 DOM 里看到的始终是实际生效的值,没有"property 和 attribute 都给了听谁的"这种规则。
事件(全部是 CustomEvent)
| 事件 | 冒泡 | 什么时候 |
|---|---|---|
| sentinel-show / sentinel-hide | 否 | visible 真正翻转时。挂载时就 visible 也会派发 show |
| sentinel-retry | 是(composed) | 点 <sentinel-error> 的重试按钮 |
| sentinel-close | 是(composed) | 点 <sentinel-pause> 的关闭按钮 |
状态机:reduceOverlay(prev, event) / INITIAL_OVERLAY / dismissPauseImage(prev) / OverlayState
主题:OVERLAY_CSS_VARS / OVERLAY_CSS_FALLBACKS / OverlayCssVar
其他:defineSentinelOverlays() / SENTINEL_OVERLAY_ELEMENTS / OVERLAY_SHARED_CSS / SentinelOverlayElement(抽象基类,不注册)
两条红线
不做团队品牌 UI(ADR-021)。所有颜色走 --sentinel-overlay-* CSS 变量,元素里没有一个写死的色值——有测试守着这条。CSS 自定义属性能穿透 shadow 边界,所以主题照常从外面注入;要改内部结构的样式走 ::part():
.player-container {
--sentinel-overlay-primary: #ff4d94;
--sentinel-overlay-radius: 12px;
}
sentinel-error::part(retry-button) {
font-weight: 600;
}不内置任何翻译(ARCHITECTURE § 8.7.1)。元素只收已经解析好的字符串——
message / retry-label / text 传什么显示什么,本包不做 key → 文案的查表。
文案解析归组合层(video-react / embed-app),每个市场的垂直行业术语各家不同,
SDK 内置一份"标准翻译"反而不准。
⚠️ 只有
retry-label有硬编码兜底('Retry')。不传就是英文,不是漏翻译的报错。
设计上值得知道的几件事
注册刻意做成显式函数,不在模块求值期自动跑。自动注册要在模块顶层碰 customElements,
而那是浏览器全局 —— SSR / next build 在 Node 里求值这个模块会当场炸。本仓库为这类事付过一次代价
(flv.js 是 UMD、模块求值期引用 self,导致 inline 模式 next build 直接失败,而单测 / e2e / 体积检查三层全碰不到)。
defineSentinelOverlays() 幂等,重复调不抛;没有 customElements 的环境直接返回 false 空转。
visible: false 时整棵子树不渲染,而不是 display: none。覆盖层里有 spinner 动画,留在树里会白白跑着。
shadow root 清空后 <slot> 也没了,所以 light DOM 里的插槽内容同样不渲染。
reduceOverlay 事件不在表里就返回同一个引用,不是新对象。seeking / timeupdate 这种高频事件
每次都造新对象会让覆盖层跟着 60fps 重刷 —— 上面示例里那句 if (next === state) return 就是靠这条。
只有 retryable 时才画重试按钮。对着一个必然失败的错误(比如 E_MEDIA_NOT_SUPPORTED)给用户
重试按钮是在骗人,点几次都不会好。retryable 的判定在 protocol 的 ERROR_META 里,是唯一真相。
spinner 尊重 prefers-reduced-motion:前庭功能障碍用户会被旋转动画诱发眩晕。
测试
pnpm --filter @sentinel-lab/video-player-ui testVitest + jsdom,直接测 custom element 的注册 / attribute → DOM / 事件派发。
