Skip to content

在播放器上做自己的 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/*.tsquality.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(进场期不该被任何东西压住)。


相关