Ceiling

利用Poster属性提升Video标签在手机端的体验

Web

在 PC 上好好的视频播放页,一到手机就出怪事:页面一打开,视频区域赫然盖着一张播放样式的三角图(居中的大播放按钮),铺满整个画面,不点它就不动。折腾一圈才发现,这张三角图既不是 hls.js 画的,也不是视频的第一帧,而是移动端内核在视频尚未播放时给出的默认占位图。本文记录这个坑的排查过程,并讲清楚 poster 属性为什么能一行解决它。

一、问题现象

一个基于 hls.js 的播放页,PC 浏览器打开即自动播放,一切正常。换到手机上就变了样:

  • 进入播放页,视频区域铺满一张播放三角图(居中的大播放按钮样式),纹丝不动;
  • 必须手动点一下,视频才开始播;
  • 这张三角图每次都在,且刚好铺满整个视频区域。

第一反应往往是:hls.js 是不是加载了封面图?是不是视频卡在了某一帧?—— 都不是。

二、排查:这张三角图是谁画的

逐一排除嫌疑人:

  1. hls.js:翻它的源码,涉及赋值的只有两处——把 MSE 的 blob 地址交给视频,以及为 iOS 的 ManagedMediaSource 追加一个 type 为 video/mp4 的 source 子节点。全程不设 poster、不建图片元素、不 new Image,它根本不负责画任何图片。
  2. 自定义加载遮罩:页面确有一个"加载中"遮罩层,但它显示正常、样式也对,不是它。
  3. 视频第一帧:一度以为是视频停在解码出的首帧(片源开场的静态图)。但定睛一看,那张图是标准的播放三角按钮样式,并不是视频画面内容——排除。
  4. 浏览器原生媒体控件:怀疑是 WebKit 的媒体控件播放键,加了 ::-webkit-media-controls-start-playback-button { display: none } 之类,结果三角图还在——说明它不是标准的 media-controls 伪元素。

排到最后,真相是:这张三角图是移动端浏览器/WebView 内核在"视频还没开始播放"时,自己画在视频区域上的默认占位图(不少移动端内核都有这个行为,尤其国产 X5/TBS、部分安卓 WebView)。它长在"封面(poster)"这一层,所以既不受 media-controls 的 CSS 控制,也不是视频帧。

三、根因:视频停在"未播放"态,内核就画默认播放占位

为什么视频会一直停在"未播放"态?因为手机浏览器默认禁止带声音的视频自动播放

播放页在流加载完成后调用了播放:

hls.on(Hls.Events.MANIFEST_PARSED, () => {
  video.play().catch(() => {})   // ← 手机上这里被拒绝
})

播放方法 video.play() 返回一个 Promise。移动端因为当前没有用户手势、且视频带声音,会以 NotAllowedError 拒绝它;而代码里 .catch(() => {}) 把错误静默吞掉了。于是链条变成这样:

  • 播放没能启动,视频一直停在"未播放"的暂停态;
  • 内核见视频迟迟不播,就在视频区域画上它默认的播放三角占位图,提示用户"点这里播放"。

这张占位图铺满视频区域,就成了用户眼里那张"每次都出现的大播放图"。PC 上自动播放宽松、一打开就播过去了,占位图一闪而过甚至不出现,所以看不到这个现象。

关键点:三角图不是 bug 画出来的,而是"自动播放被拦 → 视频停在未播放态 → 内核显示默认播放占位"这条正常链路的产物。

四、解决方案:用 poster 顶替默认占位图

既然这张三角图长在"封面(poster)"这一层,是内核在没有指定 poster 时用的默认占位,那解法就很直接了:给视频显式设置一张 poster,把内核的默认占位顶替掉

设一张纯黑图即可:

<video
  playsinline
  poster="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='9'%3E%3Crect width='16' height='9' fill='%23000'/%3E%3C/svg%3E">
</video>

这里用一段 SVG data URI 当纯黑占位图,好处是零请求、零依赖,不必额外放一张图片文件。改完在手机上一看:那张播放三角图消失了,视频区域变成干净的黑底,配上自制的播放按钮,点了就播,问题解决。

这也正好点题——poster 的本义就是"视频播放前显示的封面图"。你不给它,内核就拿自己的默认占位(那个播放三角)来填;你给了它,显示的就是你的图。所以 poster 不只是"锦上添花的封面",更是夺回播放前画面控制权的手段。

当然,poster 更常见的正向用法是放一张和视频同比例的封面图,让用户在播放前就知道这是啥,体验更好:

<video playsinline poster="/static/img/cover-16x9.jpg"></video>

五、poster 盖不住时的兜底

大多数情况下,一张 poster 就能顶掉内核的默认播放占位。但也有例外:个别内核会把播放三角当成真正的控件,画在 poster 之上,这时 poster 就盖不住了。两道兜底:

一是回到 CSS,把标准媒体控件的播放键也一并隐藏(对付那些认 WebKit 伪元素的内核):

video::-webkit-media-controls-start-playback-button,
video::-webkit-media-controls-overlay-play-button {
  display: none !important;
}

二是盖一层播放前遮罩,监听 playing 事件,真正开始播放再淡出——不管内核怎么画,播放前都稳稳盖住:

<div class="player-box">
  <video id="v" playsinline poster="..."></video>
  <div class="video-mask"></div>
</div>
.player-box { position: relative; }
.video-mask {
  position: absolute;
  inset: 0;
  background: #000;
  pointer-events: none;      /* 点击穿透,不影响下层的自制播放按钮 */
  transition: opacity .3s;
}
.player-box.playing .video-mask { opacity: 0; }
const box = document.querySelector('.player-box')
const v = document.getElementById('v')
v.addEventListener('playing', () => box.classList.add('playing'), { once: true })

遮罩层比 poster 更可控,代价是多一个元素和一点状态管理,切集重新加载时记得把 playing 类名去掉、重新监听。

选择建议:先上 poster(一行属性、成本最低,本次就是它解决的);若某些内核仍露出播放三角,再叠加 webkit 控件 CSS 或 playing 驱动的遮罩层。

六、避坑清单

  1. 手机端"video 上莫名的播放三角大图",先想到是内核默认占位,而不是 hls.js、也不是视频第一帧。hls.js 只负责喂流,不画任何图片。
  2. 它长在 poster 层,所以 ::-webkit-media-controls-* 的 CSS 往往对它无效;显式设置 poster 才是对症的解法。
  3. video.play() 一定要处理 reject。移动端自动播放普遍被拦,NotAllowedError 是常态;.catch(() => {}) 会把问题彻底藏起来,排查时先在这里打条日志。
  4. 想自动播就得静音。移动端一般只放行 muted + playsinline 的自动播放,带声音的必须由用户手势触发。
  5. poster 用同比例图,否则会被拉伸或留黑边;纯色占位可直接用 SVG data URI,省掉一次图片请求。
  6. playsinline 别忘。少了它,iOS 会把视频丢进系统全屏播放器,你的自制控制条和 poster 全都会失效。

七、小结

  • 手机上"铺满 video 的播放三角图",根因是自动播放被拦、视频停在未播放态,内核便画出默认的播放占位图;跟 hls.js、跟视频第一帧都没关系。
  • 这张占位长在 poster 层,::-webkit-media-controls 的 CSS 治不了它;显式设置一张 poster(纯黑 SVG data URI)即可顶替,是成本最低、也最对症的解法。
  • 个别内核把播放三角画在 poster 之上时,再叠加 webkit 控件 CSS 或 playing 驱动的遮罩层兜底。
  • 顺带把 video.play() 的 Promise 拒绝、muted 自动播放、playsinline 这几个移动端视频的老坑一并记牢。