纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器
全程无后端、无框架、无 Electron,一个 index.html + 一个 app.js,用 File System Access API 把浏览器变成了能播几十 GB 本地片源的播放器。本文复盘完整思考过程与 5 个实战大坑,代码可复用。
一、事情是怎么开始的
家里攒了几百 GB 的片源,MKV/TS/FLV 混着放。用播放器软件看吧,界面丑、倍速别扭、还老记不住看到哪;传到网盘再在线看吧,上传几小时、会员还死贵。
然后我盯着 Chrome 想:浏览器不是能播视频吗?直接拖进去不就完了?
结果现实很骨感:<video> 标签确实能播,但——
- 拖一个 4K MKV 进去,要么直接转圈,要么内存飙到几个 GB;
- TS/FLV 这种格式 Chrome 根本不认;
- 想拖动进度条,光标一松,画面卡成 PPT。
于是决定自己写一个「本地视频流式播放器」,纯前端,核心目标就三个:
- 不整载入内存:几十 GB 的文件,读多少播多少;
- 格式通吃:MP4/MKV/MOV 原生播,TS/FLV 用 mpegts.js 转封装;
- 能随便拖:进度条任意位置跳转,和在线视频一样顺滑。
成品长这样(截图是初始界面,左侧媒体库 + 右侧播放区 + 底部真实数据状态栏):
二、整体架构:一个页面,三条播放链路
先说结论,架构就一句话:原生格式走 URL.createObjectURL(File),TS/FLV 走 mpegts.js + 自定义 FileLoader,m3u8 走内置下载器,全部基于 File System Access API 的文件句柄按需读盘。
这里有个反直觉的知识点必须先说清楚:
URL.createObjectURL(file)并不会把整个文件读进内存。File对象是磁盘的懒加载引用,浏览器按需读盘。所以原生格式路径(MP4/MKV)天然就是流式的,你只管把 blob URL 丢给<video>。
真正麻烦的是 TS/FLV。mpegts.js 是为 HTTP 设计的,默认用 RangeLoader 走 fetch,但 file:// 根本没法 fetch。于是要给 mpegts.js 写一个自定义 FileLoader,把磁盘文件伪装成"网络流"喂给它。大坑从此开始。
三、坑 ①:分片必须 188 字节对齐,否则解析错乱
TS 流的基本单位是 188 字节的 TS 包,每个包以 0x47 开头。mpegts.js 会按 188 字节网格去切包解析。
我的第一个版本:直接 file.slice(offset, offset + 512KB) 读分片,然后传给 mpegts.js。结果——花屏、卡死、时长乱跳,而且不是偶发,是必现。
排查半天,问题出在一个数学细节上:
// ❌ 512 * 1024 = 524288,不是 188 的整数倍
// 524288 ÷ 188 = 2788.76…,第 2788 个 TS 包被拦腰截断
const CHUNK_SIZE = 512 * 1024;
被截断的半截包,mpegts.js 会当成一个"新包"去解析,整个 PES 载荷从此错位,解析全乱。
解法:把分片大小对齐到 188 的整数倍:
// ✅ 512KB 向下取整对齐:524100 字节 = 2788 个完整 TS 包
const CHUNK_SIZE = Math.floor(512 * 1024 / 188) * 188;
同源第二坑:chunk 必须是 ArrayBuffer,不是 Uint8Array
对齐只是第一层。修完还是偶尔乱码,最后挖到一个更阴的坑:
mpegts.js 内部用 new Uint8Array(chunk, offset, len) 切包。这个三参数构造的 offset 参数只对 ArrayBuffer 源生效;如果你传的是 Uint8Array,它会无视 offset 整份拷贝,切出来的全是错位数据。
// ✅ 必须传 ArrayBuffer
const chunk = await file.slice(offset, end).arrayBuffer();
这个坑的可怕之处在于:不报错、不崩溃,只是悄悄给你错位数据,表现出来就是偶发花屏。遇到这种"玄学 bug",先怀疑类型转换,别急着怀疑算法。
四、低内存:三道刹车,防止浏览器把自己撑爆
MSE 路径的读盘循环很容易写成"无脑狂读"。我加了三道刹车,让内存占用稳定在一个播放窗口内:
① 预读门控:缓冲超前超过 20 秒就暂停读盘,等播放消耗再继续:
_paceAllowed() {
const v = S.video;
if (!v || !v.buffered || !v.buffered.length) return true; // 尚无缓冲,需要起步数据
const end = v.buffered.end(v.buffered.length - 1);
return (end - v.currentTime) < READAHEAD_SEC; // READAHEAD_SEC = 20
}
② mpegts lazyLoad:缓冲超 30s 自动挂起加载,恢复阈值 5s。
③ 后向缓冲清理:已播过 20s 的缓冲自动丢弃(autoCleanupMaxBackwardDuration: 20)。
效果就是:几十 GB 的文件,播放中内存始终只有几百 MB。底部状态栏实时显示 读取: xx MB / 缓冲: xx s / 内存: xx MB,这是我调内存时盯得最多的数字。
五、坑 ②:TS 没有全局索引,时长得自己探
TS 是纯流式容器,文件里根本没有时长字段,播放器无从得知总长度 —— 进度条直接废掉,续播也做不了。
我的方案:读文件头和文件尾各 2MB,解析 PAT → PMT 找到视频 PID,再算首末视频 PES 的 PTS 差值 = 时长。
// 头尾 PTS 差即时长(TS 时间戳 90kHz)
const diff = tailPes[tailPes.length - 1].pts - firstPts;
return { duration: diff / 90000, videoPid, firstPts };
顺带一提:这个探测结果不只服务时长。videoPid 和 firstPts 是下一篇"任意位置 seek"的地基,一鱼三吃。
FLV 就简单了,直接在头部 AMF 里找 onMetaData.duration 字段读出来。
另外还有个体验细节:几千集的目录,不可能给每个文件都探测时长。我用了 IntersectionObserver + 并发 3 + 结果缓存到 IndexedDB(4000 条上限),列表滚到哪、才探测哪,超大目录零卡顿。
六、彩蛋:报错也要"有文化"——从字节流里嗅探真实编码
浏览器播不了视频时,默认报错是"解码失败"四个字。但用户真正的困惑是:文件是不是坏了?
于是我加了个"法医"功能:读取文件头 2MB,扫描 00 00 01 起始码定位 SPS NAL,用 Exp-Golomb 解码出 profile 和色深,然后给出精确诊断:
- 命中 10bit H.264 → "Chrome/Edge 内置解码器仅支持 8bit H.264,请用 VLC(文件本身完好,非损坏非加密)"
- 命中 HEVC → "浏览器通常不支持 HEVC 解码,请用 VLC"
- 嗅探不到 → 回退通用提示(MP4 的 SPS 在 avcC box 里,没有起始码,嗅不到很正常)
一个小功能,却把"这破浏览器不行"的抱怨,变成了"你的文件是 10bit 压的,浏览器没救了"的确定性结论。
七、还有一堆"人味"细节
- 兼容模式:Firefox/Safari 没有 File System Access API,自动回退到
<input type="file" webkitdirectory>,只是少了句柄持久化; - 授权持久化:
navigator.storage.persist()申请持久化存储,句柄授权跨浏览器重启依然有效; - 首次手势恢复:页面加载时无用户手势不能
requestPermission,就监听首次点击/按键,在手势内自动重新授权并打开上次的文件夹 —— 实现"重开网页自动回到上次的片库"; - 历史续播:IndexedDB 存播放进度 + 缓存文件句柄,点一下"继续播放"直接接着看,不需要重新选文件;
- 倍速/音量持久化:localStorage 记住你的 1.5x 和音量,重启不丢。
八、总结
这篇文章讲了浏览器播本地大视频的完整方案,核心可复用技巧:
- File 对象是磁盘懒加载的,
createObjectURL不会整载入内存,大胆用; - TS 分片必须 188 对齐,chunk 必须传 ArrayBuffer(传 Uint8Array 会静默错位);
- 低内存三件套:预读门控 + lazyLoad + 后向缓冲清理;
- TS 时长 = 头尾 PTS 差,顺手还能拿到 PID 和 firstPts;
- 错误信息要"可诊断":SPS 嗅探把"无法播放"变成"10bit 请用 VLC"。
下一篇讲讲最硬核的部分:TS 文件任意位置拖动 —— mpegts.js 对 TS 的 seek 是空操作,我是怎么用"CBR 估计 + IDR 定位 + PAT 回扫 + 子流重建"实现等效 m3u8 跳分片的,还有顺手写出来的 m3u8 下载器(12 线程池 + 广告过滤)。
如果这篇文章对你有帮助,欢迎收藏备用。你平时遇到过哪些"浏览器播视频"的玄学坑?评论区聊聊。
(项目源码为本地单页应用:index.html + app.js(约 2800 行)+ mpegts.js,纯原生 JS,无构建依赖。)