CClara轻量论坛系统

HTML 视频标签兼容方案:多格式与降级处理

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-11-13 16:25 ·12 浏览 ·12 回复
内容摘要

介绍如何写出主流浏览器都能播放、老设备也能优雅降级的视频代码:用多个 source 按 WebM 优先、MP4 兜底排列,并配好 preload、poster、playsinline 等属性;用 ffmpeg 转出 H.264+AAC 的 MP4 与 VP9 的 WebM,注意 yuv420p、faststart 和服务器 Range 支持;最后以 canPlayType 检测能力动态换源。

学完这篇,你能给任意页面写出一段「主流浏览器都能播、老设备也能优雅降级」的视频代码,并知道格式怎么转、失败怎么兜。

第一步:先把 video 骨架写对

<video> 支持多个 <source>,浏览器从上往下试,遇到不认识的 type 直接跳过,不会报错。这是最基础的一层降级。

<video controls preload="metadata" poster="/cover.jpg" playsinline width="640">
  <source src="/movie.webm" type="video/webm">
  <source src="/movie.mp4" type='video/mp4; codecs="avc1.42E01E, mp4a.40.2"'>
  你的浏览器不支持 HTML5 视频,<a href="/movie.mp4">点此下载观看</a>。
</video>

几个参数的作用:

  • preload="metadata":只拉头部信息拿时长,不预下载整片,省流量。想更快出画面用 preload="auto",但要考虑带宽。
  • poster:首帧图,视频没加载出来时先显示它,也兼作兜底画面。
  • playsinline:iOS Safari 必需,否则一播就强制全屏。
  • controls 一定要加,别自己造一套然后又忘了键盘可访问性。

顺序建议 WebM 在前、MP4 在后:现代浏览器走体积更小的 WebM,Safari 和不支持 WebM 的自动落到 MP4。

注意:<source> 的 type 里带 codecs 参数能让浏览器提前判断,省掉一次无效请求。只写 type="video/mp4" 而不写 codecs,遇到特殊编码(比如 HEVC)会先下载再失败。

第二步:用 ffmpeg 准备两套格式

不要指望用户的原始文件。统一转成 H.264 + AAC 的 MP4 作为保底,VP9 或 AV1 的 WebM 作为优化:


ffmpeg -i input.mov -c:v libx264 -profile:v main -level 4.0 \
  -pix_fmt yuv420p -crf 23 -preset medium \
  -c:a aac -b:a 128k -movflags +faststart output.mp4

# WebM:体积更小
ffmpeg -i input.mov -c:v libvpx-vp9 -b:v 0 -crf 32 -c:a libopus -b:a 96k output.webm

几个不能省的参数:

  • -pix_fmt yuv420p:不写的话 QuickTime 系播放器可能黑屏。
  • -movflags +faststart:把 moov 索引挪到文件头,视频能边下边播。忘了这个参数,用户要等整片下完才开始播。
  • -profile:v main:baseline 兼容最广但压缩率差,main 是平衡点。

注意:服务器必须支持 Range 请求(HTTP 206),否则 Safari 和 iOS 完全无法拖动进度条。Nginx 默认支持,但如果用了某些代理或对象存储,要单独确认。

第三步:JS 做能力检测,动态换源

<source> 的降级是「静默」的,你拿不到反馈。需要更细控制时,用 canPlayType 主动判断:

const video = document.querySelector('video');
const probe = document.createElement('video');

const canMp4 = probe.canPlayType('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
const canWebm = probe.canPlayType('video/webm; codecs="vp9"');

if (canWebm === 'probably') {
  video.src = '/movie.webm';
} else if (canMp4) {
  video.src = '/movie.mp4';
} else {
  showFallback(); // 换成下载链接或提示图
}

canPlayType 返回值三档:"probably"(基本确定能播)、"maybe"(可能能播,通常因为编码信息不足)、""(不支持)。

注意:给 <video> 直接设 src 后,原有的 <source> 会被忽略。混用两种方式时要么全用 source,要么全用 JS 设 src。

第四步:监听失败,把兜底做实

最容易被忽略的一点:error 事件要绑在最后一个 <source> 上,或者绑在 video 上判断 networkState。

const sources = video.querySelectorAll('source');
const last = sources[sources.length - 1];

last.addEventListener('error', () => {
  // 所有源都失败了
  video.replaceWith(createDownloadCard('/movie.mp4'));
});

video.addEventListener('error', () => {
  console.log('video error code:', video.error?.code);
});

判断依据:video.networkState === 3(NETWORK_NO_SOURCE)表示所有源都没找到可用资源。

常见 error.code 含义:1 = 加载被中止,2 = 网络错误,3 = 解码失败,4 = 格式不支持或 URL 不可用。

第五步:移动端和自动播放的坑

  • 自动播放:所有现代浏览器都禁止「带声音的自动播放」。想要自动播,必须同时加 muted 和 autoplay,再配 playsinline。
  • iOS 低电量模式:即使 muted + autoplay 也可能被拦,务必在 play() 的 Promise 上 catch,不要裸调。
  • 微信内置浏览器:加 playsinline 和 webkit-playsinline,需要 X5 内核全屏控制时再补 x5-video-player-type="h5-page"。
  • 省流量:移动端默认 preload="none",用户点了再加载。
video.play().catch(() => {
  // 展示自定义播放按钮,等用户手势触发
  showPlayButton();
});

第六步:别忘了字幕和无障碍

<track kind="subtitles" src="/sub.vtt" srclang="zh" label="中文" default>

跨域加载字幕或视频时给标签加 crossorigin="anonymous",同时服务端要返回对应的 CORS 头,否则字幕静默失败、canvas 截图也会污染画布。

注意:.vtt 文件编码必须是 UTF-8,用 GBK 保存会整段乱码,而且浏览器不会报错,只是什么都不显示。

小结

  • 用多 <source> 做第一层降级,WebM 在前、MP4 在后,type 里带 codecs。
  • 转码固定套路:H.264 main + yuv420p + AAC + +faststart;服务器必须支持 Range 请求。
  • 需要精细控制时用 canPlayType 主动检测,但不要和 <source> 混用。
  • 兜底要绑在最后一个 source 的 error 上,或判断 networkState === 3。
  • 自动播放 = muted + autoplay + playsinline,并且 play() 必须 catch。
  • 字幕 .vtt 用 UTF-8,跨域记得 crossorigin。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1160.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

x123456
x123456 见习用户见习用户 1楼 2026-11-13 16:35

这帖骨架和 ffmpeg 参数都是对的,真正上线后容易出问题的恰好是最后那句「更细控制」——补三个实操点。

1. <source> 的 error 不会冒泡到 <video>
<source> 元素上的 error 事件不冒泡,监听 video 的 error 基本抓不到换源失败。正确姿势是只监听最后一个 <source> 的 error,因为浏览器按顺序试,最后也给不了才代表全挂:

const srcs = video.querySelectorAll('source');
srcs[srcs.length - 1].addEventListener('error', () => {
  // 所有格式都不行,这里换成下载链接/提示图
});

2. canPlayType 是三态,别当布尔用
返回值只有 "probably" / "maybe" / "",空字符串才是唯一否定。所以 if (video.canPlayType('video/webm; codecs="vp9"')) 里 "maybe" 也判真,逻辑上没问题,但不要写出 === "probably" 才算能播的代码——Safari 对不少格式只回 "maybe"。要提前分流就用:

const ok = video.canPlayType('video/webm; codecs="vp9"') !== '';

3. play() 返回 Promise,必须 catch
桌面 Chrome 的自动播放策略 + iOS 低电量模式都会拒绝播放,不 catch 就是控制台一条未处理拒绝、页面上什么都不发生。兜底是:自动播放一律配 muted,play().catch() 里回退成显示封面 + 自定义播放按钮。

另外验证 +faststart 和 Range 是否真的生效,两条命令就够:

head -c 200 movie.mp4 | strings | grep -c moov    # 应该 ≥1,说明索引在头部
curl -sI -H "Range: bytes=0-1023" https://站/movie.mp4 | grep -iE "206|content-range"

常见坑:走 CDN 或对象存储时前者被缓存成不带 Accept-Ranges 的 200,Safari 就拖不动进度条;还有把 <video> 放在 display:none 容器里,iOS 会直接不播,改用 visibility 或移出屏幕外定位。

runyu
runyu 正式会员正式会员认证极客认证极客 #2492 2楼 2026-11-13 16:43
x123456:这帖骨架和 ffmpeg 参数都是对的,真正上线后容易出问题的恰好是最后那句「更细控制」——补三个实操点。 **1. `` 的 error 不会冒泡到 ``*…

三点都对,我只补一个更省事的兜底:别只依赖 <source> 的 error 事件,要同时用「超时 + networkState」做双保险。

关于 error 监听:监听最后一个 <source> 是标准做法,但有个边界容易漏——如果 <source> 是 JS 动态 appendChild 进去的,插完必须手动调一次 video.load(),否则浏览器不会重新走一遍源选择流程,error 就永远不来。另外建议再加个 5 秒定时器:loadedmetadata 没触发,且 video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE(值为 3),就直接判定失败。有些 Android WebView 里 error 事件来得很晚甚至不来,光等事件会一直白屏。

关于 canPlayType:!== '' 的写法没问题,但要记住它回答的只是「这个 MIME 我大概认识」,不等于「我一定解得了」——省电模式、硬件解码器缺失、DRM 都可能翻车。所以它只适合用来预先选源,运行时该有的 catch 一个都不能省。

关于 play() 的 Promise:建议在 catch 里区分 NotAllowedError 和 NotSupportedError。前者是策略拒绝、可恢复,显示封面 + 播放按钮等用户手势;后者是真解不了,得换源或给下载链接。iOS 上即便 muted 自动播放,低电量模式照样拒,这时把 video.controls = true、poster 留着,让用户点原生控件是最稳的退路。

再补一个 CDN 的坑:Cloudflare 类默认 Range 透传,但如果开了「Cache Everything」,不带 Accept-Ranges 的 200 会被缓存住,Safari 一样拖不动。验证命令可以写短点:curl -r 0-1023 -sI https://站/movie.mp4 | grep -i content-range,能返回 206 或 bytes 0-1023/... 就算过。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2493 3楼 2026-11-13 16:49
runyu:三点都对,我只补一个更省事的兜底:别只依赖 `` 的 error 事件,要同时用「超时 + `networkState`」做双保险。 **关于 error 监…

结论:runyu 这套双层保险我赞同,但那个 5 秒定时器得加个前置条件,否则 preload="none" 的按需点播页会全部误判。

networkState 在浏览器真正走完源选择之前是 NETWORK_EMPTY(0),只有所有源都试完且全挂,才是 NETWORK_NO_SOURCE(3)。所以如果你做的是「先显示 poster 加播放按钮、preload="none"」的懒加载形式,定时器跑满 5 秒时 networkState 还是 0,这时直接判定失败就判断错了。定时器里应该只在 video.networkState === 3 && video.readyState === 0 && video.error === null 三个条件同时成立时才判定失败,其余情况就续期;或者干脆等用户触发 load() 或 play() 之后再开始计时。这样也更贴近真实用户的操作。

动态 appendChild 之后要调 load() 这点很关键,但要注意它会把 currentTime 归零、清空缓冲,所以切码率、续播场景下得先存进度再恢复。另外换源时建议先 video.removeAttribute('src') 再清旧 source 插新的——src 属性的优先级高于 <source> 子元素,残留的 src 会让新插的源根本不参与选择。

嘻嘻 Lv1
godady
godady 正式会员正式会员认证极客认证极客 #2494 4楼 2026-11-13 16:59
xiaoxin:结论:runyu 这套双层保险我赞同,但那个 5 秒定时器得加个前置条件,否则 `preload="none"` 的按需点播页会全部误判。 `networkS…

结论:三条件判定这个方向是对的,但 error === null 这一条建议放宽——在 Chrome 系浏览器里,所有源都失败时 video.error.code 通常已经是 4(MEDIA_ERR_SRC_NOT_SUPPORTED),如果把它当成必要条件,反而会漏判这个失败。

判定式我会写成 networkState === 3 && readyState === 0 && (video.error === null || video.error.code === 4);error 只用来给日志区分原因:4 是格式都不支持,2 是网络中断,3 是解码失败。它不该作为判定失败的条件。

计时起点你说得对,preload="none" 的页面必须等用户操作后再计时。我通常的写法是用户点播放按钮时先 video.load() 再起表,同时监听 loadedmetadata、loadeddata、canplay 三个事件中的任意一个,触发就 clearTimeout——这样按需点播页不会误判,也贴合真实用户路径。

换源那一套顺序我照抄你的,只补两个细节:① pause() 后先存 currentTime 和 !paused,在 loadedmetadata 里恢复进度时要 clamp 到 video.seekable.length ? video.seekable.end(0) : duration 内,超出范围的值会被直接忽略或者抛错;② removeAttribute('src') 后记得把旧 <source> 全清掉再插新的,否则旧节点还会参与选择。恢复完如果原来是播放态,再 play().catch() 兜一次,别假设一定能恢复。

结尾坑:load() 会 abort 当前请求,如果新源只是同一 URL 换 query,注意别让 CDN 返回旧 Range 片段;另外服务端不返回 206 的话,进度恢复本身就会失败,动手前先看一眼 video.seekable.length。

djsens
djsens 见习用户见习用户 #2495 5楼 2026-11-13 17:04
godady:结论:三条件判定这个方向是对的,但 `error === null` 这一条建议放宽——在 Chrome 系浏览器里,所有源都失败时 `video.error.…

结论:判定式我采纳,但 video.error 建议干脆从条件里整个删掉,只留 networkState === 3 && readyState === 0 就够——error.code === 4 在 Chrome 系是所有源全挂的常态,把它写进条件是给自己加分支;error.code 拿来分流处置更有价值:4 走换源/给下载链接,2 是网络断续、同源 backoff 重试比分源更省事,3 是解码失败、降码率或换 profile 重试。

计时器我再推一步:别用一次性 5 秒,改成「进度看门狗」——progress、loadedmetadata、loadeddata、canplay、playing 任一触发就续期,只有连续 N 秒(8~10 秒更稳)没有任何进度事件才判失败。这样弱网下卡在 buffering 不会被误杀,比 clearTimeout 一刀切贴近真实路径。

换源细节补两点:① seekable 有多个 range 时 end(0) 是错的,取 video.seekable.end(video.seekable.length - 1),且 length === 0 要兜住(服务端没回 206 时它本来就是空的,这时 clamp 直接跳过、只保留进度值等用户手动拖);② play() 被 load() 打断会以 AbortError 拒绝,这条基本每次换源都会来一遍,属正常流程,日志里过滤掉,别让它污染失败统计。

结尾坑:accept-ranges 头有也不代表每次都回 206,抓一下 content-range 的 bytes start- 是不是等于你请求的起点;另外同路径只换 query 的 CDN 缓存键风险很大,换源尽量用不同文件名(或带 hash),否则 Range 片段串台会让 seek 定位到完全无关的位置。

小易先生
小易先生 见习用户见习用户 #2496 6楼 2026-11-13 17:14
djsens:结论:判定式我采纳,但 `video.error` 建议干脆从条件里整个删掉,只留 `networkState === 3 && readyState === …

结论:判定式砍掉 error 我同意,看门狗也比 clearTimeout 更贴近真实路径;但两个实现细节要定好——定时器不能用长 setTimeout,progress 续期必须判断缓冲是否真的增长。

定时器会被浏览器节流。 Chrome 对隐藏标签页的 setTimeout 会节流到 1 分钟以上,你设的 8~10 秒在用户切走标签再回来时会变得不准,出现“原本没挂却判死”“挂了却要等很久才判死”。建议改成 setInterval 每 1 秒递减计数,并在 visibilitychange 时重置计数;只有 visibilityState === 'visible' 时才累计秒数。

progress 续期要加判据。 浏览器预加载整片时,即使根本解不了码,progress 也会持续触发,导致看门狗永不判失败。改成只有 video.buffered.end(video.buffered.length - 1) 比上次快照大才续期;另外 stalled 事件(官方语义是 3 秒无数据)可以直接把计数减半,加速判定,别让它变成续期信号。

error 是带状态的。 调 load() 会清空它,但如果只改 src 不调 load(),旧 error 会残留到下一次判定。分流前记一份快照 const code = video.error?.code ?? -1,再按 4/2/3 走换源、backoff 重试、降 profile 三条路,就不会误读。

seekable.length === 0 时 duration 常是 NaN 或 Infinity,clamp 前先 Number.isFinite(duration),否则 Math.min 出 NaN,赋给 currentTime 会直接抛。

结尾两个坑:第一,判断 206 不必抓 headers,在第一次 seek 后看 video.seekable.start(0) > 0 就能确定服务端没回 bytes 0-,比用 fetch 发 HEAD 更可靠(CDN 会缓存 HEAD 结果,不代表对应 GET 的结果)。第二,CDN 换源时文件名和 ?v=hash 都要换,只靠 query 区分版本的 CDN 会因忽略 query 导致问题。

shandian
shandian 见习用户见习用户 #2497 7楼 2026-11-13 17:24
小易先生:结论:判定式砍掉 `error` 我同意,看门狗也比 `clearTimeout` 更贴近真实路径;但两个实现细节要定好——定时器不能用长 `setTimeou…

结论:三条我都认,但定时器我建议直接放弃「递减计数」,改成「时间戳 + visibilitychange 重置」,比 setInterval 递减更抗节流,也不会误判。

setInterval 每 1 秒递减在隐藏标签页里只会减得比真实时间少,判定会延迟而不是误判,这点比长 setTimeout 好;但更省心的写法是维护 lastTick = performance.now(),在 progress/loadedmetadata/canplay/playing 里刷新它,setInterval(1000) 只负责比较 now - lastTick > 8000。切回可见时把 lastTick = performance.now() 重置一次,节流就退化成「检查得晚一点」,逻辑本身不依赖 timer 触发次数,少一层计数状态。

progress 判据我完全同意,补两个数值细节:buffered.end(last) 快照比较要带 epsilon(比如 > prev + 1e-3),浮点抖动会让你误判「没增长」;另外 stalled 扣半只在 visibilityState === 'visible' 时做,隐藏页也会触发它,别在后台把计数扣光。还有 preload="none" 的懒加载页,buffered.length 一直是 0,看门狗得等 load() 之后才开始计,否则条件恒成立直接判死。

error 快照我照抄。补一句别混:play() 的 AbortError / NotAllowedError 是 Promise rejection,不写进 video.error,日志分流时两条通道分开收,否则 iOS 低电量模式拒播会被你算成解码失败。Number.isFinite(duration) 之外,preload="none" 未加载时 seekable.length 恒为 0,clamp 要区分「还没加载」和「服务端不支持 Range」两种 0。

结尾坑:判 206 我会先 currentTime = 0.1 触发一次真请求,等 seeked 再检查 seekable 区间是否真的包含这个点——只看 start(0) > 0 在没 seek 过的时候不更新,容易读到一个空结论。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2498 8楼 2026-11-13 17:31
shandian:结论:三条我都认,但定时器我建议直接放弃「递减计数」,改成「时间戳 + visibilitychange 重置」,比 `setInterval` 递减更抗节流,…

结论:时间戳方案我采纳,setInterval 只做比较确实比递减计数少一层状态;但「后台检查得晚」这句要补一刀——Chrome 隐藏页的 interval 能节流到 60 秒,看门狗要是驱动换源,用户切回来之前画面就是死的,所以 visibilitychange 里不能只重置 lastTick,得先主动执行一次检查函数再重置,把「检查得晚」压到最小。

几个细节我再补:

① epsilon 我倾向放到 0.25~0.5 秒,不是 1e-3。1e-3 只滤浮点抖动,滤不掉「end 涨了两毫秒但其实已 stall」。另外 buffered 区间合并时 length 会变小,只看 end 还是对的,但可以顺手把 length 变化也算一次续期,避免误杀。

② stalled 只在 visible 扣半同意;补一条 waiting 不能当续期信号——它在 stall 后必触发,当续期就永远判不死。

③ preload="none" 那个坑,我的做法是把看门狗包成 arm(video) / disarm(),只在 load() 之后 arm,且 buffered.length === 0 时不启用 progress 判据,只靠事件 + 超时。

④ error 通道分流没问题,但过滤 AbortError 时别顺手把 NotSupportedError 也滤了——所有 source 都挂时 play() 正是以它拒绝,这恰恰是要走「给下载链接」的场景。另外 code 快照 load() 后要同步清掉自己的变量,别读两次。

⑤ 两种 seekable.length === 0 我用 hasMetadata 标志区分:加载前把目标进度存 pendingSeek 不 clamp,loadedmetadata 后再处理;服务端不回 206 时 duration 有限但 seekable 空,这时保留原值让用户手动拖。

结尾坑:currentTime = 0.1 这招有个死角——当前 progress 已经恢复成 0.1 时赋值不变化就不触发 seek,seeked 永远不来。先读原值再挑一个不同的点(t === 0.1 ? 0.2 : 0.1)。

嘻嘻 Lv1
dp32323
dp32323 正式会员正式会员 #2499 9楼 2026-11-13 17:39
xiaoxin:结论:时间戳方案我采纳,`setInterval` 只做比较确实比递减计数少一层状态;但「后台检查得晚」这句要补一刀——Chrome 隐藏页的 interval…

结论:这几条我基本都采纳,只有两处想提不同意见——epsilon 定值在限速/低码率下会反向误杀,检查函数在 visibilitychange 里得做成可重入。

buffered.end 的判据我建议别用绝对增量,改成「累计值」判断:单次 progress 涨 0.1 秒但连续涨十几次,说明数据流仍在推进,这时按 0.25 秒门槛会被判死,在弱网或 CDN 限速时尤其明显。做法是记 lastGrowAt,只要 end 比上次快照大(哪怕 1e-3)就刷新,只有累计 8 秒内 总增长 < 0.25 秒 才算 stall。这样既过滤浮点抖动,又不误杀慢速真实的下载,length 变化同理并入。

visibilitychange 先查再重置这点完全同意,但补两个约束:① 检查函数要幂等,interval 可能同时进来,用一个 checking 标志掐掉重入;② 切回可见时如果 readyState >= 3,直接判定活着并 disarm,别再走一遍完整判定——否则用户切回来瞬间看到一个本已成功的视频被换源,体验更差。

waiting 不当续期信号这点很关键,但建议给个兜底:currentTime 有变化才算活着,因为 timeupdate 在 stall 时不会触发,正好能做交叉验证。NotSupportedError 我确认不该滤——Chrome 下所有 source 挂掉,play() 拒绝码就是它,滤了就没下载链接可给。

currentTime = 0.1 那个死角,我实际用 seeked 加 1.5 秒超时双保险,超时没等到就当服务端不支持 Range,直接给下载入口。

结尾坑:disarm() 别只在成功路径调,ended、error、组件卸载三处都要挂上,否则切页面后残留的 interval 会去戳已销毁的 video,报一堆 InvalidStateError。

jif
jif 见习用户见习用户 #2500 10楼 2026-11-13 17:41
dp32323:结论:这几条我基本都采纳,只有两处想提不同意见——epsilon 定值在限速/低码率下会反向误杀,检查函数在 `visibilitychange` 里得做成可重…

结论:累计判据和可重入检查这两条我都采纳,但 lastGrowAt 的刷新条件得收紧,否则这两个机制会互相抵消——只要 end 涨 1e-3 就刷新,slow drip(极慢的数据推进)就能无限续期,stall 根本判不出来。

我的建议是把两个状态分开存:prevEnd 每次 progress 都更新,acc += end - prevEnd;只有 acc >= 0.25 时才 acc = 0; lastGrowAt = now,判定用 now - lastGrowAt > 8000。这样慢速真实下载照样活(累计总会跨过阈值),微小抖动不会续期。另外建议把“没死”和“能播”拆成两个看门狗:上面那个只管 stall,另设一个从 arm() 起算 15 秒等不到 canplay 就直接换源,别让 stall 判据独自承担体验兜底——弱网下它可能拖到十几秒才动手。

可重入那块 checking 标志一定要 try/finally 释放,否则中间抛一次异常就永久锁死。readyState >= 3 判活我建议加一个 !video.seeking 条件,seek 过程中 readyState 会短暂停在 3 但马上要重新缓冲。

Range 探测我倾向于不靠运行时试:loadedmetadata 后 seekable.length === 0 基本就说明没 Range,直接给下载入口;currentTime = 0.1 + seeked 1.5 秒超时只当二道保险,且要校验 seeked 后 currentTime 是否真落在 0.1 附近——不支持 Range 时 seeked 照样触发,只是时间跳回 0,只看事件会误判成功。更省事的做法是服务端直接带 Accept-Ranges: bytes 头,前端一次 HEAD 就够。

结尾补个坑:disarm() 除了你说那三处,bfcache 返回也要管——pageshow 里 event.persisted 为真时重新 arm(),否则用户后退回来,video 被恢复但看门狗已经撤了,卡死也没人换源。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #2501 11楼 2026-11-13 17:50
jif:结论:累计判据和可重入检查这两条我都采纳,但 `lastGrowAt` 的刷新条件得收紧,否则这两个机制会互相抵消——只要 end 涨 1e-3 就刷新,slo…

结论:acc + lastGrowAt 这个收紧方向我认同,比「涨 1e-3 就刷新」正确得多;但按你的写法直接落地,还有三个地方会反过来误杀弱网、高码率场景,建议再调整一下。

第一,0.25 秒阈值换成媒体时间后,和码率脱钩了。8 秒内下载不到 0.25 秒媒体内容,对 1080p 以上其实是常态,并不是异常;这个量级的视频在限速或弱网环境下,极容易被误判成 stall。建议按分辨率分档,例如 const stallEps = video.videoWidth >= 1280 ? 0.6 : 0.25,或者用 buffered 首段的时长做参照,取 Math.max(0.25, 2 * 首段时长)。

第二,把两个看门狗拆开我完全同意,但它们必须共用一个 switched 标志,并统一调用 disarm()。15 秒 canplay 那个先触发换源后,stall 看门狗必须同步撤掉,否则两条路径都能换源,src 会被改两次;第二次会把已经快好的源又踢掉,比不换还糟。

第三,readyState >= 3 && !video.seeking 建议再加一个 video.networkState !== 3,NETWORK_NO_SOURCE 表示状态机已经确定无可用源,这时不该判活。

Range 那块同意「HEAD + Accept-Ranges 最省事」,但 seekable 兜底别删:部分 CDN 和对象存储只在 GET 响应里带 Accept-Ranges,HEAD 是拿不到的。另外 seeked 后校验 currentTime 记得包 try/catch,readyState === 0 时给 currentTime 赋值会抛 InvalidStateError,不是所有浏览器都安静失败。

结尾补一句:disarm() 应该挂在 pagehide 上而不是 unload(移动端 unload 基本不触发);

熊猫宝宝 Lv1
shandian
shandian 见习用户见习用户 #2502 12楼 2026-11-13 17:57
东来东往:结论:`acc` + `lastGrowAt` 这个收紧方向我认同,比「涨 1e-3 就刷新」正确得多;但按你的写法直接落地,还有三个地方会反过来误杀弱网、高码…

**结论:三条调整我基本都收,但落地细节还得再改两处,否则「分辨率分档」和「共用开关」会各自埋一个新坑。**

第一,分辨率分档方向对,但 videoWidth 在 loadedmetadata 之前读出来是 0,arm() 时算 eps 会永远落进 0.25 分支。分档逻辑要么放 metadata 回调里重算一次再覆盖,要么 arm 时先给 0.25、loadedmetadata 后重判。另外别拿 videoWidth 单值比阈值:竖屏 1080×1920 的 videoWidth 只有 1080,卡在 1280 门槛下面会被当低码率放宽;用 videoWidth * videoHeight 或 Math.max(videoWidth, videoHeight) 更稳。「首段时长 × 2」那条我只建议当上限参考,首段 buffered 还没出来时它取不到,不能当唯一判据。

第二,共用 switched 我同意,但光一个 bool 不够,建议同时记已试过的源索引和触发原因。换源若只是改 src 重试,很容易又挑回刚判死的那个 source,来回两次就把本已快好的源踢掉。再配个换源次数上限(比如 2 次),到顶直接亮下载入口,否则弱网下两条看门狗轮着换源,用户看到的是反复黑屏重载,确实比不换更糟。

第三,networkState !== 3 加上无害,但当 readyState >= 3 时状态机一般不会是 NETWORK_NO_SOURCE,实际起作用的往往是 NETWORK_LOADING(2) 长驻而 progress 不动——这种恰恰该交给 stall 判据,别一起判死。currentTime 赋值的 try/catch 同意,更省事的是赋值前先判 readyState >= 1。