Appearance
在播放器上做自己的 UI
Sentinel 不带业务 UI——默认只有 xgplayer 的原生控件。取消静音按钮、暂停封面、 进场倒计时、推荐列表、清晰度菜单,这些全部由你来写。
这是刻意的(ADR-021)。但"不给 UI"不等于"不管你怎么做"——本页把 8 个真实场景 逐个拆开,每个都指向仓库里可运行的参考实现。
先理解一件事:覆盖层叠在播放器外面
无论哪种接入方式,你的 UI 都是绝对定位在播放器容器之上的兄弟元素:
<div class="relative"> ← 定位上下文
<VideoPlayerFrame /> ← SDK 播放器(iframe 或 inline)
<UnmuteButton class="absolute bottom-6 ..." /> ← 你的 UI
<AutoplayCta class="absolute inset-0 ..." />
</div>iframe 模式下你摸不到播放器内部 DOM
跨域 iframe,没法把按钮塞进播放器内部。所有 UI 都只能叠在外层。
实际影响:
- 播放器全屏时,你的覆盖层会被留在页面里 —— 需要监听全屏状态自己处理层级
- 你没法改 xgplayer 原生控件栏的样式,只能整个关掉(
controls: false)自己画
三条通用规律:
| 怎么做 | |
|---|---|
| UI 什么时候显示 | 订阅事件(autoplayblocked / ended / compatwarning …) |
| UI 操作怎么生效 | 调命令(play() / setMuted() / setQuality() …) |
| UI 的数据从哪来 | ready 事件的 payload(duration / quality / subtitles) |
参考实现在哪
仓库里有一份完整的团队封装层,Vue 3 + Vant + Tailwind:
examples/team-video-vue/
├── TeamVideoPlayer.vue ← 通用播放器(把下面的组件都串起来)
├── LivePreviewCard.vue ← 首页直播卡片
├── VideoPageWithRec.vue ← 播放页 + 推荐列表
├── AuthGatedVideoPlayer.vue ← 权限门槛(登录享高清)
└── components/ ← 12 个可单独使用的覆盖层组件下面每个场景都对应其中的真实文件,不是伪代码。
场景 1 · 取消静音按钮
为什么需要:自动播放几乎必须配 muted(否则浏览器直接拒), 于是用户看到的是无声视频,得给他一个显眼的开声入口。
驱动信号:你自己维护的 isMuted 状态 + volumechange 事件回写。
vue
<UnmuteButton :visible="isMuted && interactive" @click="handleUnmute" />ts
async function handleUnmute() {
await player.setMuted(false)
isMuted.value = false
}参考:
components/UnmuteButton.vue位置放在底部居中(bottom-6 left-1/2 -translate-x-1/2),避开原生控件栏。
场景 2 · 自动播放被拒的 CTA
为什么需要:iOS / 移动端在没有用户手势时会拒绝 play()。 如果不处理,用户看到的是一个静止的黑框,以为视频坏了。
驱动信号:autoplayblocked 事件。
vue
<script setup lang="ts">
import { VideoPlayerFrame } from '@sentinel-lab/video-vue-frame'
import { ref } from 'vue'
const src = 'https://your-cdn/video.m3u8'
const showAutoplayUI = ref(false)
</script>
<template>
<VideoPlayerFrame :source="src" autoplay @autoplay-blocked="showAutoplayUI = true" />
</template>vue
<AutoplayCta :visible="showAutoplayUI" @play="handleCtaPlay" />这是 E_AUTOPLAY_BLOCKED 不可重试的原因
自动重试 play() 一百次都会被拒——必须有真实用户手势。 所以 SDK 把它做成一个独立事件而不是普通错误:它需要的不是重试,是一个按钮。
参考:
components/AutoplayCta.vue
场景 3 · 重连失败的重试覆盖层
驱动信号:reconnectstart / reconnectsuccess / reconnectfailed 三件套。
vue
<script setup lang="ts">
import { VideoPlayerFrame } from '@sentinel-lab/video-vue-frame'
import { ref } from 'vue'
const src = 'https://your-cdn/video.m3u8'
const showRetryUI = ref(false)
function onReconnectStart({ attempt, maxAttempts }: { attempt: number; maxAttempts: number }) {
toast(`重连中… ${attempt}/${maxAttempts}`)
}
</script>
<template>
<VideoPlayerFrame
:source="src"
@reconnect-start="onReconnectStart"
@reconnect-failed="showRetryUI = true"
/>
</template>SDK 已经替你重试过若干次(ReconnectPlugin),reconnectfailed 意味着 自动手段已经耗尽,这时才该打扰用户。
参考:
components/RetryOverlay.vue
场景 4 · 进场封面 + 倒计时 ⭐
为什么需要:点进直播间到首帧出画,中间有拉流握手 + 解码的空窗。 不遮的话用户看到的是一段黑屏,体感像卡死。铺一张全尺寸封面 + 倒数 3 秒, 等待就从"不知道要多久"变成"还有 3 秒"。
这个和 source.poster 不是一回事:
source.poster(SDK 提供) | 进场封面覆盖层(你写) | |
|---|---|---|
| 有倒计时 | ❌ | ✅ |
| 控制何时开播 | ❌ | ✅ |
| 可跳过 | ❌ | ✅ |
| 全屏尺寸裁切 | 由浏览器决定 | 你控制(object-fit: cover) |
vue
<IntroCoverOverlay
:visible="showIntro"
:config="{
duration: 3,
image: coverUrl,
onComplete: startPlayback,
}"
@skip="startPlayback"
/>ts
async function startPlayback() {
showIntro.value = false
try {
await player.play()
} catch {
showAutoplayUI.value = true // 被拒了,退回场景 2 的 CTA
}
}别让覆盖层自己调 play()
倒计时归零就 play() 看起来很自然,但自动播放被拒时覆盖层已经消失、视频没动, 用户对着黑屏一脸茫然。
让父组件在 onComplete 里调 play(),失败了还能把 AutoplayCta 顶上来—— 上面那段 try/catch 就是干这个的。
参考:
components/IntroCoverOverlay.vue
场景 5 · 播完倒计时 + 推荐列表联动
为什么需要:看完自动播下一个,是留存的关键动作。
驱动信号:ended 事件。倒计时在你这边跑(setInterval), SDK / iframe / 契约一律不参与——这是 ADR-025 的边界。
推荐列表的位置:播放器下方,而倒计时覆盖层压在播放器中央。 两者通过 onTick 联动,推荐列表第一项显示同步的进度条:
ts
const config = {
type: 'countdown',
duration: 5,
text: '{seconds} 秒后播放:下一集',
onTick: ({ remaining }) => (countdownRemaining.value = remaining),
onComplete: () => goToNext(),
}vue
<!-- 推荐列表第一项 -->
<div v-if="index === 0 && countdownRemaining !== null">
{{ countdownRemaining }}s
<div :style="{ width: `${((duration - countdownRemaining) / duration) * 100}%` }" />
</div>倒计时覆盖层不要用 <Transition>
Vue 过渡靠 requestAnimationFrame 分步执行,而"看完自动播下一集"最常见的场景 恰恰是后台标签页——那里 rAF 被浏览器节流,过渡会卡住不移除元素。
覆盖层的显隐是功能不是装饰,直接 v-if 最稳。
参考:
components/EndOverlay.vue+VideoPageWithRec.vue
场景 6 · 清晰度菜单
数据来源:ready 事件的 payload.quality。 切换:setQuality(level),'auto' 交回 ABR。 回报:qualitychange 事件(ABR 自动切换也会触发)。
vue
<script setup lang="ts">
import type { QualityLevel } from '@sentinel-lab/video-vue-frame'
import { VideoPlayerFrame } from '@sentinel-lab/video-vue-frame'
import { ref } from 'vue'
const src = 'https://your-cdn/video.m3u8'
const qualityLevels = ref<QualityLevel[]>([])
</script>
<template>
<VideoPlayerFrame
:source="src"
@ready="({ quality }) => (qualityLevels = quality)"
@quality-change="({ level, auto }) => { /* 更新选中态 */ }"
/>
</template>必须处理"没有档位"的情况
quality 可能是空数组,有两种情况:单码率流(本来就只有一档), 以及 iOS < 17.1 —— 它既没有 MSE 也没有 ManagedMediaSource,只能回落原生 HLS, 清晰度由系统接管,SDK 读不到档位列表。
iOS ≥ 17.1 和桌面 Safari 现在读得到了。 ADR-056 之后它们走 hls.js, 这一段从前写的「iOS / Safari 走原生,读不到」只对 iOS < 17.1 还成立。
不加判断的话用户会看到一个空菜单:
vue
<QualityMenu v-if="levels.length >= 2" ... />>= 2 而不是 > 0:只有一档也没得选,菜单是多余的。
参考:
components/QualityMenu.vue
场景 7 · 高清需要登录(占位档位)
为什么需要:大多数直播平台的高码率要登录 / 充会员。 高清的 URL 后端根本不会下发给未登录用户,所以菜单里那一项是个纯占位—— 点它不发生任何码率切换,只抛一个事件给业务层。
vue
<QualityMenu
:levels="visibleLevels"
@select="handleQualitySelect"
/>ts
function handleQualitySelect(level) {
if (level.loginRequired && !isLoggedIn.value) {
emit('loginRequired', level) // ← 只抛事件,不切码率
return
}
player.setQuality(level.index)
}登录成功后,如果用户之前点过高清,自动帮他切过去——这个"记住意图"的细节 决定了体验是顺畅还是恼人。
文案是你的,不是 SDK 的
"高清(登录享)"这类文案完全由你传入,SDK 不内置任何业务文案。 参考实现放在 i18n/*.ts 的 quality.hdLoginRequired,6 种语言。
参考:
AuthGatedVideoPlayer.vue+components/QualityMenu.vue
场景 8 · 浏览器兼容横幅
驱动信号:compatwarning 事件(警告,不中断播放)。
UC / 夸克会强行把视频劫持成自己的内置播放器,你的 UI 全部失效。 SDK 检测到之后抛警告,由你决定怎么提示。
vue
<script setup lang="ts">
import { VideoPlayerFrame } from '@sentinel-lab/video-vue-frame'
import { ref } from 'vue'
const src = 'https://your-cdn/video.m3u8'
const compatWarning = ref({ visible: false, ua: '' })
function onCompatWarning({ code, ua }: { code: string; ua: string }) {
if (code === 'W_BROWSER_INCOMPATIBLE') compatWarning.value = { visible: true, ua }
}
</script>
<template>
<VideoPlayerFrame :source="src" @compat-warning="onCompatWarning" />
</template>参考:
components/CompatWarningBanner.vue
还有两个:字幕菜单 / 弹幕输入框
同样的模式,不再展开:
- 字幕:
ready.subtitles拿轨道 →setSubtitle(id)切换 →subtitlechange回报。 ⚠️ 字幕加载失败会抛E_SUBTITLE_LOAD_FAILED(不中断播放),记得给个提示—— 否则用户点了切字幕但什么都没发生,会以为是你的按钮坏了。 参考components/SubtitleMenu.vue - 弹幕:输入框和数据源全在你这边,SDK 只负责渲染。
pushDanmaku(item)推一条,setDanmakuEnabled(false)整体关掉。 参考components/DanmakuInput.vue
主题怎么传
参考实现用 CSS 变量,播放器容器上挂一层:
ts
const cssVars = computed(() => themeToCssVars(props.theme))vue
<div class="relative" :style="cssVars">覆盖层组件读 var(--team-video-primary)。这样换品牌色只改一个对象, 不用逐个组件改样式。
SDK 不参与主题
这些 CSS 变量是你的,不是契约的一部分。SDK 只提供播放能力, 换皮肤、换品牌色、换圆角全在你的封装层完成。
完整串起来长什么样
TeamVideoPlayer.vue 是把上面所有组件串起来的参考:
vue
<div class="relative w-full h-full" :style="cssVars">
<VideoPlayerFrame ref="playerRef" v-bind="playerProps" @ready="..." @ended="..." />
<IntroCoverOverlay :visible="showIntro" :config="introConfig" @skip="startPlayback" />
<UnmuteButton :visible="isMuted" @click="handleUnmute" />
<AutoplayCta :visible="showAutoplayUI" @play="handleCtaPlay" />
<RetryOverlay :visible="showRetryUI" @retry="handleRetry" />
<CompatWarningBanner :visible="compatWarning.visible" :browser="compatWarning.browser" />
<QualityMenu v-if="levels.length >= 2" :levels="levels" @select="handleQuality" />
<EndOverlay :visible="showEnd" :config="endConfig" @cancel="handleCancel" />
</div>每个覆盖层各管各的显隐条件,互不耦合。层级从低到高: 普通按钮 z-10 → 进场封面 z-12(进场期不该被任何东西压住)。