video_cacher 0.3.0
video_cacher: ^0.3.0 copied to clipboard
Offline video cache for Flutter - mp4/HLS(m3u8) download with resume, AES-128, pure-Dart remux to mp4, album export. 离线视频缓存:断点续传、纯 Dart 转封装、相册保存。
video_cacher #
Flutter 离线视频缓存包:无 ffmpeg、无 SQLite、无捆绑二进制。下载、转封装、 持久化全是 Dart;唯一的原生代码是一段几十行的平台通道,把 HLS 的 AES-128 解密 交给系统加密 API(硬件 AES 指令),系统 API 不可用时自动退回纯 Dart。
English — video_cacher is a Flutter package for offline video caching. It
downloads direct mp4 links and HLS (m3u8) streams with resumable downloads,
expired-URL refresh and AES-128 segment decryption, then remuxes the TS segments
into a local mp4 (h264 + AAC, no transcoding) inside a background isolate, and
can optionally save the result to the system photo album. No ffmpeg, no SQLite,
no bundled binaries — the only native code is a thin platform channel that
routes AES-128-CBC to the OS crypto library (javax.crypto on Android,
CommonCrypto on iOS) so it runs on the CPU's AES instructions, with a
pure-Dart fallback everywhere else.
dependencies:
video_cacher: ^0.3.0
final cacher = VideoCacher.instance;
await cacher.ensureInitialized();
final task = await cacher.enqueue(
id: 'video_1001',
name: 'Episode 1',
cover: 'https://example.com/cover.jpg',
url: 'https://example.com/play.m3u8',
);
cacher.taskStream.listen((e) {
print('task=${e.taskId} status=${e.status.name} progress=${e.progress}');
});
The rest of this document is in Chinese.
能力 #
- 下载
mp4直链和m3u8(HLS),自动识别源类型;识别错误可自愈——mp4 任务 嗅探到 m3u8 内容会自动纠正为 HLS 流程,同一轮完成 - 断点续传:ETag 持久化 +
If-Range校验内容未变,服务端内容变更时自动从 0 重下; mp4 流中途瞬断按 Range 有限重试 - 暂停/继续/取消/插队、并发调度(任务级 + HLS 分片级并发均可配)
- 通过
setRefreshUrl回调刷新过期下载地址(404/410 自动触发,覆盖 入口 m3u8、key、ts、mp4 四类地址);刷新后 HLS 变体按带宽锁定,不混码率 - HLS AES-128 分片解密走系统硬件 AES(见下节),不卡主线程
- 纯 Dart 转封装(remux,不转码):h264 + AAC 的 TS → 本地
mp4; 跑在独立 isolate(主线程零冻结),mdat 流式落盘(内存峰值约 1 倍输入), 取消立即生效 - 进度事件按任务节流(约 10 次/秒),remuxing 阶段有逐分片真实进度
- 任务用 JSON 持久化,App 重启可恢复
- 成片可自动/手动保存到系统相册(基于 photo_manager)
硬件 AES(为什么这个包是 Flutter plugin) #
加密 HLS 的解密量等于整段视频大小。纯 Dart 的 AES 在真机上实测约 1MB/s (已开 3 路 isolate 并行),433MB 的加密视频光解密就要 400 秒——占整个「处理中」 阶段的 98%:
phases: decryptWait=400425ms demux=6524ms build=1866ms
ARMv8 CPU 有 AES 指令,系统加密库直接用它,是 500-2000MB/s 量级。所以 0.3.0 起本包重新成为 Flutter plugin,只为把这一步交给系统:
| 平台 | 实现 | 线程 |
|---|---|---|
| Android | javax.crypto.Cipher("AES/CBC/PKCS5Padding")(Conscrypt → BoringSSL) |
插件内固定线程池,不占平台主线程 |
| iOS | CommonCrypto 的 CCCrypt(kCCAlgorithmAES + kCCOptionPKCS7Padding) |
后台 DispatchQueue,不占平台主线程 |
| 其他(macOS/桌面/测试) | 无原生实现 | 自动退回纯 Dart(pointycastle) |
关键点:
- 不捆绑任何二进制:没有
.so、没有.xcframework、没有 vendored framework,原生侧就是一个 Kotlin 文件 + 一个 Swift 文件,只调用系统 API, 对包体积的影响可以忽略。 - 永远有纯 Dart 兜底:主 isolate 启动 remux 前用一个固定向量做一次能力 探测(同时校验 PKCS7 去填充语义一致),探测不过就整轮走 pointycastle; 单片解密失败也只退回这一片,不影响其余分片。
- 产物逐字节一致:硬件路径与纯 Dart 路径的明文必须完全相同,仓库里有 单测(纯 Dart 路径 sha256 固定)与真机 integration test(两条路径逐字节比对) 各自把关。
javax.crypto的 PKCS5 对 16 字节分组即 PKCS7,与 HLS 规范一致。
remux 时日志会打出当前用的是哪条路径,真机可直接确认:
[video_cacher.crypto] AES backend: platform(hardware)
[video_cacher.transmux] phases: decryptWait=…ms demux=…ms build=…ms
dart(software fallback) 则表示走了纯 Dart。
升级注意:0.3.0 新增了原生代码,必须完整重建(
flutter clean后重装), 热重载/增量安装不会带上新插件。
进度语义 #
downloadedBytes / totalBytes 的量纲随阶段变化,progress 恒为 0..1:
| 阶段 | downloadedBytes / totalBytes 含义 |
|---|---|
| mp4 下载(running) | 已下载字节 / 文件总字节 |
| HLS 下载(running) | 已完成分片数 / 总分片数 |
| remuxing | 已喂入的输入字节 / 分片总输入字节(第二段 0..1) |
| completed | 回填为最终 mp4 文件字节数(两者相等) |
总长未知(如服务端不回 content-length)时 totalBytes 为 0、progress 为 0。
UI 若要单一进度条,可按状态把 running 与 remuxing 两段各自映射后拼接。
已知限制 #
- h265(HEVC) 暂不支持(支持计划中):remux 在首个含 PMT 的分片即
fail-fast,不会空跑完全部分片;error 形如
UnsupportedStreamException: PMT stream types: [...] — only h264+aac supported。 音轨仅支持 AAC-ADTS。 - 不支持的 HLS 播放列表特性会在下载任何分片前明确报错
(
UnsupportedPlaylistException),不再静默产出坏数据: 非 AES-128 加密(如 SAMPLE-AES)、key 轮换(多个不同 EXT-X-KEY)、EXT-X-MAP(fMP4)、EXT-X-BYTERANGE、EXT-X-DISCONTINUITY。 - 音频时间轴间隙不补偿:源流音频有缺口时,成片可能出现渐进音画偏移。
- 磁盘高水位约 2× 视频大小:分片与成片并存,remux 成功后才清理分片。
- remuxing 阶段暂停后恢复会重新转封装(转封装不可断点)。
- 单文件 >4GB 未支持(mp4 box 使用 32 位长度)。
安装 #
dependencies:
video_cacher: ^0.3.0
本包是 Flutter plugin(只为硬件 AES,见上文),无需任何额外配置:Android 侧
最低 minSdk 21,iOS 侧最低 12.0,都不引入第三方依赖。
权限说明 #
保存相册依赖 photo_manager,需要以下权限。
Android #
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
iOS #
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要把导出的视频保存到系统相册。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以保存和查看导出的视频。</string>
快速开始 #
1. 初始化 #
final cacher = VideoCacher.instance;
cacher.setRefreshUrl((id) async {
// 根据业务 id 向你自己的后端查询最新可下载地址。
final result = await api.fetchLatestPlayUrl(id);
return result.url;
});
await cacher.ensureInitialized();
2. 创建任务 #
final task = await cacher.enqueue(
id: 'video_1001',
name: '第 1 集',
cover: 'https://example.com/cover.jpg',
url: 'https://example.com/play.m3u8',
saveToAlbum: false,
);
3. 监听任务事件 #
final sub = cacher.taskStream.listen((e) {
print('task=${e.taskId} status=${e.status.name} progress=${e.progress}');
});
4. 常用控制 #
cacher.pause(task.taskId);
cacher.resume(task.taskId);
cacher.prioritize(task.taskId);
await cacher.cancel(task.taskId, deleteFiles: true);
await cacher.deleteTask(task.taskId);
await cacher.setMaxConcurrency(3);
5. 保存到相册 #
final result = await cacher.copyToAlbum(task.taskId);
print('ok=${result.ok}, error=${result.error}');
// 也可以直接按本地路径保存:
await cacher.copyPathToAlbum(task.mp4Path!, title: task.name);
日志开关 #
VideoCacherLog.verbose 控制全部下载/remux 链路日志,默认开启;
release 构建建议关闭:
VideoCacherLog.verbose = false;
API 概览 #
VideoCacher #
setRefreshUrl(Future<String> Function(String id)? fn)ensureInitialized({DownloadConfig config})enqueue({required id, required name, required cover, required url, bool saveToAlbum = true})pause(String taskId)/resume(String taskId)/prioritize(String taskId)cancel(String taskId, {bool deleteFiles = false})deleteTask(String taskId)setMaxConcurrency(int n)copyToAlbum(String taskId)/copyPathToAlbum(String path, {String? title})taskStream—Stream<TaskEvent>tasks—Map<String, DownloadTask>只读快照dispose()
任务状态 #
queued / running / remuxing / paused / completed / failed / canceled
URL 刷新约定 #
setRefreshUrl 在下载中遇到 404 / 410 时触发(HLS 的入口 m3u8、key、ts 与
mp4 直链都覆盖)。回调参数是任务的 taskId,需返回完整可下载的新地址,不能为空。
单次回调有超时(DownloadConfig.refreshTimeout,默认 30s),挂起按该次失败处理。
App 重启后的行为 #
- 任务从 JSON 存储恢复
- 之前处于 running/queued/remuxing 的任务统一转为
paused - 不自动续传,需用户手动继续
Example #
示例工程在 example/lib/ 下,演示了初始化、创建任务、列表管理、暂停/继续/删除、本地播放和相册保存。