前言
在 Yam TV 这个跨平台影视 App 中,Flutter 端需要实现一个完整的视频下载功能:支持 m3u8 流媒体下载、结合已有的 HLS 去广告代理、后台下载不中断、断点续传,以及完整的下载管理 UI。本文分享整个方案的设计与实现。
问题背景
Yam TV 是一个影视聚合 App,Flutter 端负责移动播放。已有的功能包括:
- 多源站搜索、播放
- HLS 视频流播放(通过本地 HTTP 代理实现去广告过滤)
- 收藏、观看历史同步
新的需求是:**用户可以将正在播放的视频下载到本地,保存为可离线播放的文件**。这看起来简单,但在移动端实现一个可靠的下载系统,需要考虑几个关键问题:
1. m3u8 是分片流(多个 .ts 分片),不是单一文件,需要逐个下载再拼接
2. 下载要通过已有的 HLS 去广告代理,确保下载的内容和播放时看到的一致
3. 切到后台时下载不能中断
4. 要有完整的下载管理:进度、暂停/继续、断点续传、删除
5. 下载完成后要能直接播放
架构设计
整体流程
用户点击下载 → 解析 m3u8 变体 → 选择画质 → 加入下载队列
↓
HlsLocalProxy 获取已过滤广告的 playlist
↓
解析分片列表,逐个通过代理下载
↓
所有分片下载完成 → 拼接为单一 .ts 文件
↓
标记完成 → 通知 → 可在下载管理页播放
数据模型
核心是 DownloadTask,用 Drift (SQLite) 持久化:
class DownloadTask {
String id; // 'sourceKey:vodId:epIndex'
String sourceKey;
String title;
int epIndex;
String qualityLabel;
String m3u8Url;
DownloadStatus status; // queued / downloading / paused / completed / failed
double progress;
int totalSegments;
int downloadedSegments;
String? filePath; // 最终文件路径
int fileSize;
DateTime createdAt;
String? tempDirPath; // 断点续传用
}
状态用 Drift 的 migration 管理,Schema v1→v2 时自动添加新列。
下载引擎
#### 队列管理(DownloadQueueNotifier)
基于 Riverpod ChangeNotifier,支持:
- **并发控制**:最多 3 个任务并行下载
- **串行执行**:超出并发的任务排队等待
- **暂停/继续**:使用 `Completer` 控制下载循环的等待与恢复
- **动态调度**:一个任务完成后自动从队列取出下一个
void _scheduleNext() {
while (_activeJobs.length < _maxConcurrency && _pendingQueue.isNotEmpty) {
final task = _pendingQueue.removeAt(0);
_startJob(task);
}
}
#### 分片下载器(DownloadJob)
每个下载任务的核心逻辑:
1. **获取已过滤的 playlist**:通过 HlsLocalProxy 的 /hls.m3u8?u=&f=1 端点,拿到已经过去广告的 m3u8 列表
2. **解析分片 URL**:playlist 中所有分片 URL 已被代理重写为 /seg?u=,直接通过本地代理下载即自动继承去广告
3. **逐个下载**:使用 dart:io 的 HttpClient,每个分片 stream 到临时文件
4. **拼接**:所有分片下载完成后,依序读取并写入单个 .ts 文件
5. **清理**:删除临时目录
for (int i = 0; i < segments.length; i++) {
final segFile = File('${segDir.path}/seg_$i.ts');
final segUri = _resolveSegUri(segments[i]);
final request = await _client!.getUrl(segUri);
final response = await request.close();
await response.pipe(segFile.openWrite());
onProgress(i + 1, total);
}
断点续传
这是用户很关心的功能。当 App 被切到后台或意外退出时,如果再次打开,已下载的部分不应丢失。
实现方式:
1. **暂停时保存临时目录路径**:在 DownloadQueueNotifier.pause() 中,将临时目录路径写入数据库
2. **恢复时检测已有分片**:DownloadJob._prepareSegDir() 检查 segments/ 目录中已有哪些 seg_$i.ts 文件
3. **跳过已存在的分片**:_findStartIndex() 扫描已有文件的最大索引,从下一个开始下载
int _findStartIndex(Directory segDir) {
if (!segDir.existsSync()) return 0;
final files = segDir.listSync().whereType<File>().toList();
int maxIdx = -1;
for (final f in files) {
final match = RegExp(r'seg_(\d+)\.ts').firstMatch(f.path);
if (match != null) {
final idx = int.parse(match[1]!);
if (idx > maxIdx) maxIdx = idx;
}
}
return maxIdx + 1;
}
Android 前台服务
为了让下载在 App 切到后台时不被系统杀死,Android 端启动了一个前台 Service:
- 下载开始时自动启动 `DownloadForegroundService`(带常驻通知栏)
- 全部下载完成后自动停止
- 使用 `ContextCompat.startForegroundService()` 兼容 Android 8+
fun start(context: Context) {
val intent = Intent(context, DownloadForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
}
下载管理 UI
下载管理页面提供完整的任务管理功能:
- **状态展示**:排队/下载中(进度条)/暂停/已完成/失败
- **操作**:暂停、继续、重试、删除(含确认弹窗)、清除已完成
- **播放**:下载完成后直接播放,复用 `YamVideoPlayer` 组件
- **打开位置**:通过 `FileProvider` + 系统文件管理器查看文件保存目录
- **筛选/排序**:按状态筛选、按时间/大小排序
本地播放器
下载完成的 .ts 文件通过 YamVideoPlayer 播放:是在 playUrl 之外新增了一个 localFilePath 参数,当传入时使用 VideoPlayerController.file() 代替 VideoPlayerController.networkUrl(),其余 UI(控制栏、倍速、进度条)完全复用。
HLS 画质选择
下载前会先解析 m3u8,检测是否为变体 playlist(多画质):
final parsed = await HlsPlaylistParser.parseUrl(m3u8Url);
if (parsed.isVariant && parsed.variants.length > 1) {
final picked = await QualityPickerSheet.show(context, variants: parsed.variants);
// 用户选择后下载对应画质的 media playlist
}
如果是单画质的 media playlist,直接开始下载,不弹选择框。
后台去广告的天然集成
这是本方案的一个亮点。Yam TV 的 Flutter 端原本就有 HlsLocalProxy——一个运行在 127.0.0.1 的本地 HTTP 代理服务器,用于在播放时过滤 HLS 广告分片。
下载功能直接复用这个代理:
- 通过 `buildHlsUrl(url, filterEnabled: true)` 获取已过滤的代理 URL
- 下载 playlist 文本时调的是代理的 `/hls.m3u8` 端点,返回的已经是去广告后的版本
- 分片也通过代理的 `/seg` 端点下载
- 去广告开关和播放页共用同一个设置
这意味着:**用户开启去广告后下载的视频,和播放时看到的内容完全一致——广告分片在 manifest 层面就被剔除了**,不需要在下载端做任何额外处理。
注意事项 & 踩坑记录
1. Android 10+ 文件共享
下载完成后用 Intent.ACTION_VIEW 打开文件时,不能直接用 Uri.fromFile()——Android 10+ 的 Scoped Storage 禁止把 file:// URI 暴露给其他 App。必须用 FileProvider 生成 content:// URI:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
2. Drift 命名冲突
Drift 从表名 DownloadTasks 自动生成 DownloadTask 数据类,和我们手写的 DownloadTask 模型冲突。解决方案:使用 @DataClassName('DownloadTaskEntry') 注解指定 Drift 生成不同的类名。
3. FlutterEngine.activity 已废弃
在新版 Flutter Android embedding 中,FlutterEngine.activity 属性已被移除。需要在 MainActivity.configureFlutterEngine() 中显式把 Activity 传给插件。
4. MIME 类型
.ts 文件的 MIME 类型是 video/mp2ts,不是 video/mp4。用错会导致 ActivityNotFoundException。
总结
整个下载功能的核心设计思路是:
1. **复用现有基础设施**:下载直接走已有的 HLS 去广告代理,零额外过滤逻辑
2. **队列 + 并发**:最多 3 个任务并行,超出排队,Android 前台服务保活
3. **断点续传**:临时目录持久化,恢复时扫描已有分片跳过
4. **完整 UI**:下载管理页、画质选择、本地播放、文件管理入口
这套方案在 Yam TV 中已落地运行,如果你也在做 Flutter 端的视频下载功能,希望本文能给你一些参考。
讨论
还没有留言,来留下第一条评论吧!