纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器

0 阅读7分钟

纯前端播 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。

于是决定自己写一个「本地视频流式播放器」,纯前端,核心目标就三个:

  1. 不整载入内存:几十 GB 的文件,读多少播多少;
  2. 格式通吃:MP4/MKV/MOV 原生播,TS/FLV 用 mpegts.js 转封装;
  3. 能随便拖:进度条任意位置跳转,和在线视频一样顺滑。

成品长这样(截图是初始界面,左侧媒体库 + 右侧播放区 + 底部真实数据状态栏):

项目主界面

二、整体架构:一个页面,三条播放链路

先说结论,架构就一句话:原生格式走 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;

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 和音量,重启不丢。

八、总结

这篇文章讲了浏览器播本地大视频的完整方案,核心可复用技巧:

  1. File 对象是磁盘懒加载的,createObjectURL 不会整载入内存,大胆用;
  2. TS 分片必须 188 对齐,chunk 必须传 ArrayBuffer(传 Uint8Array 会静默错位);
  3. 低内存三件套:预读门控 + lazyLoad + 后向缓冲清理;
  4. TS 时长 = 头尾 PTS 差,顺手还能拿到 PID 和 firstPts;
  5. 错误信息要"可诊断":SPS 嗅探把"无法播放"变成"10bit 请用 VLC"。

下一篇讲讲最硬核的部分:TS 文件任意位置拖动 —— mpegts.js 对 TS 的 seek 是空操作,我是怎么用"CBR 估计 + IDR 定位 + PAT 回扫 + 子流重建"实现等效 m3u8 跳分片的,还有顺手写出来的 m3u8 下载器(12 线程池 + 广告过滤)。

如果这篇文章对你有帮助,欢迎收藏备用。你平时遇到过哪些"浏览器播视频"的玄学坑?评论区聊聊。

(项目源码为本地单页应用:index.html + app.js(约 2800 行)+ mpegts.js,纯原生 JS,无构建依赖。)