ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

原生 JS 实现 HLS 播放器:m3u8 解析与 MediaSource 实战

原生 JS 实现 HLS 播放器:m3u8 解析与 MediaSource 实战 做视频这一行的朋友大概都遇到过这种局面手里有一堆 m3u8 索引文件想在一个页面里把它们播起来结果引一个 hls.js 进来打包体积直接涨了两百多 KB项目里还多了一层不知道什么时候会变的黑盒。tiancai-video-hls就是我为了摆脱这种依赖而写的一个小东西——用原生 js从零实现一个HLS 视频播放器不引任何第三方库直接把m3u8索引拉下来、解析、喂给浏览器的 MediaSource最后渲染成画面。整套代码压缩前不到一千行没有构建步骤静态服务器一扔就能跑特别适合那些只需要播放、不需要复杂业务逻辑的内嵌场景也适合想搞明白 HLS 到底怎么在浏览器里跑起来的同学拿来对照阅读。这篇文章我会把这个播放器的思路、核心代码、踩过的坑全部摊开讲包括 MPEG-TS 分片为什么不能直接丢给 video 标签、分片后缀名是.png时该怎么处理、拖动进度条之后为什么容易花屏这些具体问题。1. 为什么值得自己撸一个播放器1.1 HLS 播放器的工作链路到底是什么先把链路说清楚不然后面很多取舍没法解释。HLS 的全称是 HTTP Live Streaming它的核心思想非常朴素把一个完整的视频文件切成一小段一小段通常是 2 到 10 秒一段每段单独存成一个文件然后写一个文本索引文件按顺序列出这些分片的地址。这个索引文件就是 m3u8。播放器要做的第一件事是把这个文本文件下载下来逐行解析出分片列表第二件事是按需下载分片第三件事是把下载到的二进制数据转换成浏览器能接受的格式塞进媒体缓冲区第四件事就是让 video 元素从缓冲区里取数据播放。这四步听起来简单但每一步都有坑。第一步的坑在于 m3u8 是一个带标签的文本格式标签有几十个不同厂商生成的风格差异很大有的把所有信息写在一行有的拆成多行。第二步的坑在于分片地址可能是相对路径可能有重定向可能带鉴权参数甚至扩展名和真实内容对不上。第三步是整个播放器最硬的部分——浏览器原生并不认识 MPEG-TS 这种封装格式你必须自己把它拆开重装。第四步的坑在于缓冲区的管理加得太多内存会爆加得太少会卡顿。很多人第一次写的时候会觉得不就是下载一个文件再喂给 video 吗写了两百行发现画面是黑的然后就去引 hls.js 了。这个反应很正常但如果你搞清楚上面四步各自在干什么会发现真正难的只有第三步而第三步在特定条件下是可以绕开的。1.2 不引 hls.js 的三个实际理由第一个理由是体积。hls.js 完整版压缩后大概在 200KB 到 400KB 之间gzip 之后也有一百多 KB。如果你的页面本身只有几十 KB为了播放一个视频把这个依赖引进来首屏加载会被明显拖慢。tiancai-video-hls 的定位就是够用就好我需要的是播放、暂停、拖动、切清晰度这几个基础能力不需要 DRM、不需要广告插入、不需要低延迟 LL-HLS 的那套复杂逻辑砍掉这些之后代码量完全可以控制住。第二个理由是可控性。用别人的库出了问题你只能去翻它的 issue 列表或者在它提供的有限回调里打补丁。自己写的播放器每一个字节从哪来、什么时候进缓冲区、缓冲区里还剩多少你都能在控制台里直接看到。线上出问题时这种透明度能省掉大量排查时间。我遇到过几次分片服务器返回 404 的情况用自研播放器可以直接把失败的分片 URL 打出来对着服务端日志核对五分钟定位问题。第三个理由是学习价值。HLS 是一个被广泛使用的协议理解它的细节对做音视频相关的工作帮助很大。你写过一遍解析器以后看任何一份 m3u8 文件都能立刻判断出它的结构、码率策略和加密方式这种判断力不是看文档能获得的。注意如果你的项目需要兼容一大堆奇奇怪怪的流媒体源、需要 DRM、需要多音轨字幕切换老老实实用成熟库别硬刚。自研播放器适合的是源格式可控 需求简单的场景。1.3 tiancai-video-hls 的整体模块划分整个播放器我拆成了五个模块彼此之间的耦合控制到最低。解析器负责把 m3u8 文本变成 JavaScript 对象只做纯函数处理不碰网络。加载器负责发请求封装 fetch、超时、重试、并发控制。缓冲区管理器负责和 MediaSource 打交道包括创建 SourceBuffer、追加数据、清理旧数据。状态机负责维护当前播放位置、已缓冲区间、加载进度这些状态。UI 控制器负责把 DOM 事件翻译成模块调用比如点击进度条就换算成时间点转交给缓冲区管理器去 seek。这样拆的好处是每一层都能单独测试。解析器我可以直接喂字符串进去断言输出加载器我可以 mock fetch缓冲区管理器在浏览器里跑UI 层只做事件转发逻辑极薄。实际开发中我把解析器的测试用例攒了三十多个覆盖了带加密、带 BYTERANGE、带 DISCONTINUITY、主播放列表这些情况后面改代码时基本没出过回归问题。2. m3u8 索引语法的核心细节2.1 一份典型 m3u8 里到底有哪些标签先看一份最普通的点播 m3u8长这样#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:9.009, segment_000.ts #EXTINF:9.009, segment_001.ts #EXTINF:3.003, segment_002.ts #EXT-X-ENDLIST第一行#EXTM3U是固定头所有 m3u8 都必须以此开头没有它就不是合法的 HLS 索引。#EXT-X-VERSION声明协议版本版本号决定了哪些标签可以用比如 BYTERANGE 要求版本至少是 4。#EXT-X-TARGETDURATION是分片时长的最大值向上取整播放器用它来估算缓冲策略。#EXT-X-MEDIA-SEQUENCE是第一个分片的序号直播场景里这个值会随着时间递增点播里一般是 0。#EXT-X-PLAYLIST-TYPE:VOD这个标签很关键它明确告诉播放器这是一个点播清单内容不会再变所以可以放心地把总时长算出来也可以直接设置进度条。如果是直播这个标签会缺失或者写成 EVENT播放器就必须每隔一段时间重新拉一次索引文件看看有没有新分片。#EXTINF后面跟的是这一段的时长单位秒可以带小数。注意它末尾有个逗号逗号后面理论上可以跟一个标题很多生成工具会留空。解析的时候千万别直接按逗号切完就取值要先处理掉可能存在的标题字段。#EXT-X-ENDLIST表示清单结束直播里没有这个标签就说明后面还会有新内容。2.2 主播放列表与码率自适应多码率的 HLS 会有一个主播放列表master playlist里面不列分片而是列多条子流的地址#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH1280000,RESOLUTION1280x720,CODECSavc1.64001f,mp4a.40.2 720p/index.m3u8 #EXT-X-STREAM-INF:BANDWIDTH640000,RESOLUTION854x480,CODECSavc1.64001e,mp4a.40.2 480p/index.m3u8解析主列表时标签是#EXT-X-STREAM-INF它的属性列表里 BANDWIDTH 是峰值带宽RESOLUTION 是分辨率CODECS 是编码格式描述。紧跟在这个标签后面的一行非注释文本就是子流的地址。这里有个容易踩的坑属性值里可能带逗号比如 CODECS 的值avc1.64001f,mp4a.40.2如果你用简单的 split(,) 去切属性整个结构就碎了。正确的做法是用正则逐个匹配KEYVALUE对VALUE 要么是被双引号包起来的一整段要么是不含逗号的裸值。function parseAttributeList(str) { const attrs {}; const re /([A-Za-z0-9-])([^]*|[^,]*)/g; let m; while ((m re.exec(str)) ! null) { let value m[2]; if (value.startsWith()) value value.slice(1, -1); attrs[m[1]] value; } return attrs; }码率自适应的实现思路是先根据navigator.connection.effectiveType或者上一次的下载速度估一个初始档位播放过程中统计每个分片的实际下载耗时算出一个实际带宽如果连续几个分片都下得比预期快就往上切一档反之往下切。切换点必须落在分片边界上不能在一段视频中间换否则解码器会错乱。我在实现里做了一个简化策略——只在缓冲区剩余时长低于 15 秒时考虑切档这样能避免频繁切换导致的画质抖动。2.3 分片后缀名是 .png 时怎么处理这个情况在实际项目里非常普遍我一开始也困惑过。看到 m3u8 里列出的分片地址全是xxx.png第一反应是索引是不是写错了或者这个文件是不是被伪装了。结论是播放器完全不需要关心分片的扩展名。HLS 协议里没有任何一条规定说分片必须是 .ts 或 .m4s分片就是一段二进制数据服务器给它起什么名字都不影响内容本身。所以处理方式很简单解析器只管把地址拼出来加载器用 fetch 拿到 ArrayBuffer然后按二进制内容本身的特征去判断格式而不是按 URL 的后缀。判断方式可以看开头的几个字节MPEG-TS 的同步字节是固定的 0x47每隔 188 字节出现一次fMP4 的开头一般是ftyp这个 box。用这个规则去识别无论名字叫.ts、.png、.jpg还是没有任何后缀都能正确判断。function sniffSegmentType(buffer) { const bytes new Uint8Array(buffer); if (bytes[0] 0x47) return ts; // ftyp box: 4 字节长度 ftyp if (bytes.length 8 bytes[4] 0x66 bytes[5] 0x74 bytes[6] 0x79 bytes[7] 0x70) return fmp4; return unknown; }这个设计带来的一个额外好处是兼容性变强了。有些服务端会用统一的后缀名 参数区分不同资源比如segment.png?a1b2如果播放器傻乎乎地按扩展名去拼 Content-Type 或者做格式假设立刻就会崩。实操心得永远不要根据 URL 后缀推断资源类型。网络请求拿回来的是字节流字节流自己会说话。2.4 AES-128 加密分片的请求链路有些 m3u8 里会出现#EXT-X-KEY标签表示后续的分片是加密存储的。这个标签的属性一般是这样的#EXT-X-KEY:METHODAES-128,URIhttps://example.com/key.bin,IV0x1a2b3c...解析时要把 METHOD、URI、IV 三个属性取出来。URI 可能是相对路径需要按 m3u8 自身地址做一次解析。IV 是初始化向量如果标签里没写就默认用分片的媒体序号MEDIA-SEQUENCE转成 16 字节大端序作为 IV。播放流程是遇到第一个带 KEY 标签的分片之前先把密钥文件下载下来用crypto.subtle.importKey导入成 CryptoKey然后对每个分片的 ArrayBuffer 调crypto.subtle.decrypt参数用{name: AES-CBC, iv}解出来的明文再喂给缓冲区。这里有两个必须注意的点。第一crypto.subtle只在安全上下文里可用也就是 https 或者 localhosthttp 页面下这个 API 直接是 undefined。第二密钥请求会带上浏览器当前的 Cookie 和 Referer如果服务端有防盗链策略需要在 fetch 里显式配置credentials。我建议把密钥请求和分片请求走同一套加载器逻辑这样超时、重试、鉴权头都能统一处理。另外要提醒一句这套流程只适用于你自己拥有授权的内容。给别人家的加密内容做适配先确认你手里有没有合法的授权这是基本的职业操守。2.5 EXT-X-MAP 与 BYTERANGE 这两个进阶标签#EXT-X-MAP出现在 fMP4 分片的场景里它指向一个初始化段init segment里面装的是 moov box包含解码器配置信息。播放器必须先把这段数据 append 进 SourceBuffer之后的分片才能被正确解码。标签格式是#EXT-X-MAP:URIinit.mp4如果分片本身就在一个大文件里还会带 BYTERANGE 属性。#EXT-X-BYTERANGE标签表示下一个分片不是完整文件而是某个文件里的一段字节范围。格式是#EXT-X-BYTERANGE:长度起始偏移起始偏移省略的话表示紧接着上一个分片的结尾继续读。这个特性在打包场景里很常见一个几 GB 的大文件配一份索引可以省掉大量小文件的开销。播放器处理它需要在 fetch 请求里加Range: bytesstart-end头服务端返回 206 状态码才算正常。这两个标签一起出现时的顺序是MAP 标签声明初始化段可能带 BYTERANGE然后每个 EXTINF 前面可能会有自己的 BYTERANGE。解析器需要把这两层范围分开记录初始化段只在开头加载一次分片范围则每个都要处理。3. 核心难点TS 分片为什么不给 video 直接用3.1 浏览器的 MediaSource 到底认什么格式这是整个项目最关键的一个认知点。video.src xxx.m3u8这种写法只有在 Safari 或者 iOS 上才有效因为 Safari 内置了 HLS 支持。Chrome、Firefox、Edge 全部不认 m3u8你把 m3u8 地址塞给 video 元素它只会当作一个无法识别的文件然后报一个DEMUXER_ERROR_COULD_NOT_OPEN。在 Chrome 上播放必须走 MediaSource ExtensionsMSE这条路。MSE 的工作方式是这样你创建一个 MediaSource 对象用URL.createObjectURL把它变成一个 blob URL 赋给 video.src然后监听 MediaSource 的sourceopen事件在这个事件里用addSourceBuffer创建一个或多个 SourceBuffer每个 SourceBuffer 声明它能接受的 MIME 类型和编码参数之后你往里 appendBuffervideo 就从缓冲区里读数据解码播放。关键就在这个 MIME 类型上。MSE 支持的容器格式主要是 ISO BMFF也就是 fMP4、mp4和 WebM。MPEG-TS 不在支持列表里。也就是说如果你的分片是 .ts 格式appendBuffer 会直接抛错。3.2 三条可行的技术路线对比面对 TS 分片实际有三条路可以走。第一条是引入一个 transmux 库把 TS 实时转成 fMP4。hls.js 内部就是这么干的它有一个完整的 MPEG-TS demuxer会解析 TS packet、PES 层、提取 H.264 的 NAL 单元和 AAC 的 ADTS 帧再按照 fMP4 的 box 结构重新封装。这条路最完整但工作量大得惊人我个人的判断是如果你需要处理任意 TS 源直接用 hls.js 比自己写划算得多。第二条是让服务端在打包阶段就输出 fMP4 分片。用 ffmpeg 的话加上-hls_segment_type fmp4参数就能生成 fMP4 分片同时会产出一个 init.mp4 作为初始化段。这种情况下播放器只需要处理二进制的搬运完全不需要做格式转换代码量能压缩到很小的规模。tiancai-video-hls 的主要优化方向就是这条路径。第三条是优先使用浏览器原生 HLS 能力。检测到video.canPlayType(application/vnd.apple.mpegurl)返回非空字符串时直接把 m3u8 地址交给 video 元素让浏览器自己去处理。这条路径在 Safari 和 iOS 上是零成本的代码只需要几十行。我的实现是三条路都留了入口优先走原生不支持原生就检查分片类型是 fMP4 就走自研 MSE 通道是 TS 且用户没有 transmux 需求就给出明确提示。3.3 fMP4 通道的实现细节走 fMP4 通道时MIME 类型的声明有两种方式差别很大。写成video/mp4是最宽松的浏览器接受任何 mp4 数据但它在 append 的时候不会校验数据是否符合你声明的编码出问题的时候你会拿到一个很含糊的错误。写成video/mp4; codecsavc1.64001f,mp4a.40.2就严格得多浏览器会按这个声明去匹配不匹配会立刻报错。我倾向后者因为错误暴露得早。codecs 字符串可以从 m3u8 主列表的 CODECS 属性里直接取如果没有主列表也可以从 init.mp4 的 moov box 里解析出来但那个解析量比较大实际项目中我一般让服务端在 m3u8 里带上这个信息。创建完 SourceBuffer 之后要立刻做一件事把 init segment append 进去。这一步完成前不要 append 任何媒体分片否则会报MEDIA_ERR_SRC_NOT_SUPPORTED。判断 init 是否加载完成的方式是监听 SourceBuffer 的updateend事件这个事件在每次 append 完成后触发。const ms new MediaSource(); video.src URL.createObjectURL(ms); ms.addEventListener(sourceopen, async () { const sb ms.addSourceBuffer(video/mp4; codecsavc1.64001f,mp4a.40.2); const initBuf await fetchArrayBuffer(initSegmentUrl); sb.appendBuffer(initBuf); // 后续分片在 updateend 里排队追加 });3.4 缓冲区的时间戳对齐问题fMP4 分片自带时间戳baseMediaDecodeTime正常情况下按顺序 append 就能连续播放。但有两种情况会让时间戳出问题。一种是分片之间有#EXT-X-DISCONTINUITY标签表示前后两段的时间轴不连续比如中间插了广告或者编码参数变了。这时候必须先把 SourceBuffer 里剩余的缓冲清理干净再 append 新的分片否则会出现花屏或者卡死。处理方式是遇到 DISCONTINUITY 就调sb.abort()清掉当前队列然后调sb.remove(0, sb.buffered.end(...))清空已缓冲区间。另一种是拖动进度条之后的重新定位。你跳到某个时间点播放器需要找到包含这个时间点的分片从那个分片开始加载。但如果你直接从中间位置 appendvideo 的 currentTime 会和你期望的位置对不上。正确处理方式是把目标分片的时间戳和当前的ms.duration对齐必要时用video.currentTime做校正。4. 加载器与播放控制的实战实现4.1 分片预加载策略与滑动窗口预加载的激进程度直接影响观感。加载太保守网速一抖动就卡因为你缓冲区里只有一两秒的数据。加载太激进内存会快速堆高尤其在长视频场景下浏览器可能直接抛QuotaExceededError。我的策略是基于缓冲区剩余时长做动态调节。每 500 毫秒检查一次sb.buffered和video.currentTime的差值也就是当前播放点前面还有多少秒已缓冲的内容。如果这个值低于 10 秒就把加载并发提到 3 个分片低于 5 秒提到 5 个高于 30 秒就暂停加载等缓冲区消耗一点再继续。function tick() { const buffered getBufferedAhead(video, sb); if (buffered 5 pending 5) schedule(5 - pending); else if (buffered 10 pending 3) schedule(3 - pending); else if (buffered 30) pauseLoading(); setTimeout(tick, 500); }滑动窗口则是清理机制。缓冲区里保留的数据不需要是全部已加载的内容已经播过去很久的部分可以删掉。我设置的是保留播放点前 30 秒、后 60 秒超出的部分调sb.remove清理。清理动作也要注意不能在 SourceBuffer 正在 updating 的时候调会抛InvalidStateError必须等updateend之后再做。注意事项remove 的区间不能和正在追加的区间重叠也不能把当前播放位置删掉。稳妥的做法是永远保留video.currentTime - 5之后的所有数据只清理更早的部分。4.2 拖动进度条时如何避免花屏拖动是用户用得最多的功能也是最容易出问题的地方。整个过程要处理好几件事把新的时间点映射到分片索引取消所有正在进行的下载清空待追加队列清理不再需要的缓冲然后从目标分片开始重新加载。时间点到分片的映射很简单就是把每个分片的 EXTINF 时长累加找到第一个累计时长超过目标时间点的分片。这里要考虑一个偏差问题m3u8 里的 EXTINF 是近似值和分片内部真实的时间戳可能有几十毫秒的差异。所以我额外维护了一个从分片索引到实际起始时间的映射表在每次 append 完成之后用sb.buffered的实际数据去修正它这样多次拖动之后的定位精度会明显提高。拖动过程中还有一个坑如果用户快速连续拖动多次前一次拖动的异步加载还在进行中新的拖动就来了这时候如果不做取消处理两批分片会交错 append 进缓冲区导致时间轴混乱。我的做法是给每次拖动分配一个递增的 generation 编号加载器在回调里比对编号不匹配就丢弃结果。4.3 播放速度、音量与清晰度切换倍速播放靠的是video.playbackRate这个属性浏览器原生支持设置成 0.5、1.0、1.5、2.0 都行。要注意的是倍速播放会加快缓冲区消耗所以切换倍速之后要立刻重新评估预加载策略把并发提上去。另外某些浏览器在 playbackRate 不为 1 的时候音频会被静音这是浏览器的实现差异不是代码问题。音量控制用video.volume范围 0 到 1。静音状态用video.muted。这里有个体验细节用户把音量拖到 0 的时候自动把 muted 设成 true这样图标状态才一致。清晰度切换本质上是切换子播放列表。切换时机必须选在分片边界上我的实现是记录待切换的档位等当前分片 append 完成并且updateend触发之后再清空后续队列用新档位的信息重新开始加载。切换点前后如果存在时间戳不连续就要按 DISCONTINUITY 的方式处理。4.4 错误重试与网络异常兜底网络请求失败是家常便饭。我的加载器里做了三层兜底。第一层是单请求级别的重试。fetch 失败或者响应状态码不在 200 到 299 之间就等 500 毫秒重试最多三次退避系数 1.5。第二层是分片级别的降级。如果某个分片重试三次还是拿不到就跳过它记录一条警告继续往后加载。跳过分片会导致画面有几秒钟的中断但总比整个播放器卡死强。同时把跳过的数量累加如果连续跳过了 5 个以上就说明不是偶发问题直接触发全局错误提示。第三层是播放器级别的恢复。如果 MediaSource 进入了closed状态或者 SourceBuffer 报出了无法恢复的错误就整个重建一遍销毁旧的 MediaSource重新创建重新从当前位置开始加载。这个恢复流程我封装成了一个recover()方法配合一个最多重试两次的计数器避免死循环。async function fetchWithRetry(url, options, retries 3) { let delay 500; for (let i 0; i retries; i) { try { const res await fetch(url, options); if (!res.ok) throw new Error(HTTP res.status); return await res.arrayBuffer(); } catch (err) { if (i retries - 1) throw err; await new Promise(r setTimeout(r, delay)); delay * 1.5; } } }5. 排查实录与踩坑清单5.1 开发者工具里看不到 m3u8 请求是怎么回事这个现象我遇到过好几次一开始以为是代码没跑起来后来发现原因有好几种得逐个排查。最常见的一种是请求根本没经过主文档的网络层。如果视频在一个 iframe 里播放而且 iframe 的内容来自不同源主页面的 Network 面板需要把上下文切到对应的 frame 才能看到请求。Network 面板顶部有一个筛选器默认是 All点开之后能看到各个 frame 的选项。第二种是请求被 Service Worker 拦截了。Service Worker 的 fetch 事件可以在网络层之前处理请求如果返回的是new Response(cache)这样的缓存响应Network 面板里可能只显示一个(ServiceWorker)的标记看不到原始 URL。排查方式是打开 Application 面板看 Service Workers 那一栏有没有注册的脚本临时取消注册再刷新。第三种是页面把请求发到了 blob URL 或者 data URL 上。这两类 URL 不会产生网络请求所以在 Network 面板里完全看不到。如果你的播放器是从内存里的字符串创建 MediaSource那就属于正常现象。第四种最简单也最容易忽略筛选器勾了 Fetch/XHR但请求实际上是别的类型。或者过滤器里输入了错误的过滤词把所有请求都过滤掉了。先清空过滤框再试一次。5.2 缓冲区相关的几个高频报错QuotaExceededError是最常见的。原因是 append 的数据量超过了浏览器给单个 MediaSource 分配的内存上限Chrome 上这个上限大概是 150MB 左右具体值跟设备和版本有关。解决办法就是前面说的滑动窗口定期清理已经播放过的缓冲区。需要注意的是remove 操作本身不会立刻释放内存得等updateend触发之后内存才真正归还所以清理策略要留出余量别等到接近上限了才开始清。InvalidStateError: Failed to execute appendBuffer一般出现在 SourceBuffer 正在 updating 的时候又调了一次 append。MSE 规定同一时间只能有一个 append 在跑必须先等updateend。我的队列实现里用一个appending标志位来控制updateend触发后自动取出下一个任务执行。MEDIA_ERR_DECODE通常意味着编码参数和 SourceBuffer 声明的 codecs 不匹配。比如视频实际是 H.265但代码里声明的是 avc1H.264浏览器解码失败就会报这个。排查方式是下载一个分片用工具看一下它的编码格式然后修正 codecs 字符串。5.3 常见问题速查表现象可能原因排查方向画面全黑控制台无报错没有 append init segment检查 EXT-X-MAP 是否被处理报 DEMUXER_ERROR分片是 TS 但走了 MSE 通道用字节头判断实际格式拖动后花屏时间戳错位或缓冲未清理检查 DISCONTINUITY 处理和 seek 逻辑播放几十秒后卡死缓冲区超限实现滑动窗口清理首次播放正常重播失败MediaSource 已被关闭重建 MediaSource 对象移动端无法播放安全上下文限制确认是 https 或 localhost密钥请求 403缺少鉴权信息检查 fetch 的 credentials 配置请求成功但无画面声明的 codecs 与实际不符核对分片的编码参数5.4 几个让我多花了两小时的细节第一个是URL.revokeObjectURL的时机。创建 MediaSource 之后我用createObjectURL生成了 blob URL 赋给 video如果在 sourceopen 之前就把这个 URL revoke 掉MediaSource 会直接失效页面一片空白而且没有任何报错。正确的做法是在播放器销毁的时候再 revoke。第二个是MediaSource.duration的设置。点播场景下必须在第一个 append 完成之后手动设置ms.duration 总时长否则进度条会显示成 Infinity 或者不可拖动。设置的位置也有讲究太早设置会报错必须在 MediaSource 进入open状态并且至少有一个 SourceBuffer 之后。ms.addEventListener(sourceopen, () { // 设置总时长必须在 sourceopen 之后 ms.duration totalDuration; });第三个是移动端的自动播放限制。iOS 和 Android 的浏览器都要求播放必须由用户手势触发video.play()在非手势上下文中返回的 Promise 会 reject。处理方式是把首次播放绑定到一个按钮点击上或者在 play() 的 catch 里提示用户点击播放。另外 iOS 上video元素默认是内联播放还是全屏播放由playsinline属性决定不写这个属性视频会强制全屏这是很多人第一次做移动端播放会踩的坑。第四个是 m3u8 的缓存。有些服务端会给索引文件设置很长的 Cache-Control这时候你在直播场景下轮询拿到的永远是同一份旧清单。解决办法是在请求 URL 后面拼一个时间戳参数或者设cache: no-store。点播场景下反而是好事能省掉重复请求。提示调试播放器的时候把每次 append 的分片序号、时间戳范围、字节长度都打到控制台出现问题的时候对照日志能快速定位到底是哪一段数据有问题。6. 关于这套代码的一些个人体会写这个播放器的过程中我最大的收获是理解了浏览器不认什么比浏览器认什么更重要。文档里列的是一堆支持的格式和 API但真正决定你方案能不能落地的是那些不支持的地方——不支持 TS 容器、不支持非安全上下文调用加密 API、不支持无用户手势的自动播放。这些限制每一条都能让你的实现思路推翻重来提前知道能省掉大量返工。另外一个体会是播放器这类基础组件的稳定性来自对异常路径的覆盖而不是对正常路径的优化。正常播放的代码可能只占三成剩下七成都在处理网络失败、格式异常、状态错乱、内存回收这些边角情况。我自己的版本迭代了七八轮每一轮都是线上暴露出来的稀奇古怪的问题从 404 重试到缓冲区超限从倍数播放导致音画不同步到切换清晰度时的短暂黑屏。如果你的项目也有播放 m3u8 的需求建议先花十分钟确认三件事分片是 TS 还是 fMP4、有没有加密、目标浏览器支持不支持原生 HLS。这三件事定了方案基本就定了。剩下的就是把这篇文章里的模块一个个填实测试用例攒起来异常路径走到位。我这边后续打算给加载器补一个基于请求耗时的带宽探测让清晰度切换的判断更准确一些另外把解析器的容错再加强一轮专门针对那种字段顺序混乱、标签拼写不规范的自制索引文件。
返回列表