跳转到主要内容

crayonxiaoxin

Flutter 端 m3u8 视频下载管理器:从下载到播放的全链路实现

前言

在 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) 持久化:

dart
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` 控制下载循环的等待与恢复
  • **动态调度**:一个任务完成后自动从队列取出下一个
dart
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:ioHttpClient,每个分片 stream 到临时文件

4. **拼接**:所有分片下载完成后,依序读取并写入单个 .ts 文件

5. **清理**:删除临时目录

dart
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() 扫描已有文件的最大索引,从下一个开始下载

dart
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+
kotlin
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(多画质):

dart
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 端的视频下载功能,希望本文能给你一些参考。

讨论

还没有留言,来留下第一条评论吧!

留下足迹