初始化
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/video_download/media_download_manager.dart';
|
||||
|
||||
/// 视频下载按钮,短视频 / 长视频 / 短剧 / 动漫共用。
|
||||
/// 只管画状态和转发点击;权限、扣次、落记录、进度回调全在 [MediaDownloadManager]
|
||||
class DownloadButton extends StatefulWidget {
|
||||
final VideoModel? video;
|
||||
|
||||
/// true 竖排(短视频那套图标),false 横排(长视频),只影响外观
|
||||
final bool isShort;
|
||||
|
||||
/// 缓存记录落哪个桶。不传时按 [isShort] 推断(短视频 / 影视 / 动漫);
|
||||
/// 短剧在操作台里排版同短视频,但要单独归类,所以显式传 [MediaStyle.Drama]
|
||||
final MediaStyle? style;
|
||||
|
||||
const DownloadButton(
|
||||
{super.key, this.video, this.isShort = true, this.style});
|
||||
|
||||
@override
|
||||
State<DownloadButton> createState() => _DownloadButtonState();
|
||||
}
|
||||
|
||||
class _DownloadButtonState extends State<DownloadButton> {
|
||||
late DownloadTask _task;
|
||||
|
||||
/// 免费下载一套图标,短视频/长视频各一套
|
||||
String get _icon => (widget.video?.isFreeDownload == true
|
||||
? "download_free.png"
|
||||
: widget.isShort
|
||||
? "download_short.png"
|
||||
: "download.png")
|
||||
.videoPath;
|
||||
|
||||
/// 保存到缓存库时的媒体类型
|
||||
MediaStyle get _mediaStyle {
|
||||
final style = widget.style;
|
||||
if (style != null) return style;
|
||||
if (widget.isShort) return MediaStyle.ShortVideo;
|
||||
return widget.video?.videoType == 1 ? MediaStyle.Cartoon : MediaStyle.Video;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_task = MediaDownloadManager.instance
|
||||
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DownloadButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
//列表/PageView 复用 State 时会换 model,会话得跟着换:不换的话按钮画的是上一条的状态,
|
||||
//点下载下的也是上一条。同一个对象(父级只是重建)就别白折腾
|
||||
if (identical(oldWidget.video, widget.video)) return;
|
||||
_task.detach();
|
||||
_task = MediaDownloadManager.instance
|
||||
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_task.detach();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 本按钮在短视频列表里随滑动大量创建/销毁,下载回调由单例持有、dispose 后仍可能被调到,统一判 mounted
|
||||
void _refresh() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isShort = widget.isShort;
|
||||
final icon = SizedBox(
|
||||
width: isShort ? 30 : 24,
|
||||
height: isShort ? 30 : 24,
|
||||
child: Image.asset(_icon),
|
||||
);
|
||||
final label = Text(
|
||||
_task.state.desc,
|
||||
style: isShort
|
||||
? const TextStyle(
|
||||
color: Color(0xffdcdcdc),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)
|
||||
: const TextStyle(color: Color(0xff989898), fontSize: 12),
|
||||
);
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _task.start,
|
||||
child: isShort
|
||||
? Column(mainAxisSize: MainAxisSize.min, children: [icon, label])
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [icon, 2.sizeBoxW, label]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/statistics.dart';
|
||||
import 'package:image_gallery_saver/image_gallery_saver.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../debug_log.dart';
|
||||
import 'video_download_manager.dart';
|
||||
|
||||
/// iOS 端 m3u8 → mp4 下载器(分片式,支持断点续传)
|
||||
///
|
||||
/// 流程:解析 m3u8 → 并发下载 ts 分片 → 落 local.m3u8 → ffmpeg `-c copy` remux 成 mp4 → 写相册。
|
||||
/// 断点续传靠「分片已存在就跳过」实现,与 Android 端 M3U8DownloadTask 同一思路,
|
||||
/// 所以暂停后再开始不会从 0 重来(旧版用 ffmpeg 直接拉远程 m3u8 输出 mp4,
|
||||
/// 中断产物只有几十字节的 ftyp 头、moov 没写,既播不了也读不出进度,只能删掉重下)。
|
||||
///
|
||||
/// 对外接口语义与 Android 的 M3u8Downloader 对齐,方便 VideoDownloadManager 统一转发。
|
||||
class IOSVideoDownloader {
|
||||
IOSVideoDownloader._();
|
||||
static final IOSVideoDownloader instance = IOSVideoDownloader._();
|
||||
|
||||
static String? _basePath;
|
||||
final Map<String, _IOSDownloadTask> _tasks = {};
|
||||
|
||||
Future<bool> _ensureInit() async {
|
||||
if (_basePath != null) return true;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final root = '${dir.path}/vPlayDownload';
|
||||
final d = Directory(root);
|
||||
if (!d.existsSync()) await d.create(recursive: true);
|
||||
_basePath = root;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 下载缓存根目录(确保已初始化),供缓存大小统计/清理使用
|
||||
Future<String> baseDir() async {
|
||||
await _ensureInit();
|
||||
return _basePath!;
|
||||
}
|
||||
|
||||
/// 下载目录的 key —— 只取 url 的 path,不能带 query。
|
||||
///
|
||||
/// realVideoUrl 形如 `.../vid/h5/m3u8/$sourceURL?token=xxx&c=$cdnAddress`,token 会刷新、
|
||||
/// cdn 会切换。拿完整 url 做 key 的话,这两者一变目录就跟着变,已下好的文件立刻失联:
|
||||
/// 列表退回用 DB 里的旧记录显示"已完成",一点"保存到相册"就报"视频文件不存在"。
|
||||
/// path 唯一对应一个 sourceURL,稳定。Android 侧 M3U8Util.getSaveFileDir 就是这么做的。
|
||||
String _dirFor(String url) {
|
||||
final hash = md5.convert(utf8.encode(VideoDownloadManager.taskKey(url))).toString();
|
||||
return '$_basePath/$hash';
|
||||
}
|
||||
|
||||
String _outputFor(String url) => '${_dirFor(url)}/download.mp4';
|
||||
|
||||
/// 查询 url 当前状态,语义同 M3u8Downloader.searchInfo
|
||||
/// - 已完成:{localPath, progress: "100.00", status: "2"}
|
||||
/// - 下载中:{isLoaderRunning: "1", progress}
|
||||
/// - 已暂停但下过一部分:{isLoaderRunning: "0", progress}
|
||||
/// (进程重启后 _tasks 是空的,只能靠磁盘上残留的分片还原进度,否则续传的进度看不见)
|
||||
/// - 未下载:null
|
||||
Future<dynamic> searchInfo(String url, {DownloadCallback? callback}) async {
|
||||
if (url.isEmpty) return null;
|
||||
await _ensureInit();
|
||||
// 必须先查活动任务再查文件:remux 阶段 outputPath 已经存在但还没写完,
|
||||
// 先 File.exists 会把进行中的任务误判为"已完成"。
|
||||
// 用 taskKey(剥掉 token/cdn) 匹配:切线路/刷 token 后整条 url 变了,用原 url 会找不到进行中的任务
|
||||
final task = _tasks[VideoDownloadManager.taskKey(url)];
|
||||
if (task != null) {
|
||||
if (callback != null) task.addCallback(callback);
|
||||
return {
|
||||
'isLoaderRunning': '1',
|
||||
'progress': task.currentProgress,
|
||||
};
|
||||
}
|
||||
final outputPath = _outputFor(url);
|
||||
if (await File(outputPath).exists()) {
|
||||
return {
|
||||
'localPath': outputPath,
|
||||
'progress': '100.00',
|
||||
'status': '2',
|
||||
};
|
||||
}
|
||||
// 没有 mp4 但目录里还留着分片 → 是暂停/中断的任务,把已下比例报出去
|
||||
final partial = await _partialProgress(url);
|
||||
if (partial != null) {
|
||||
return {
|
||||
'isLoaderRunning': '0',
|
||||
'progress': partial,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 用磁盘上残留的分片估算已下进度:已下分片数 / 总分片数 * 下载权重。
|
||||
/// 总数从上次落盘的 manifest 读,避免为了算进度再联网拉一次 m3u8。
|
||||
Future<String?> _partialProgress(String url) async {
|
||||
try {
|
||||
final dir = _dirFor(url);
|
||||
final manifest = File('$dir/segments.count');
|
||||
if (!manifest.existsSync()) return null;
|
||||
final total = int.tryParse((await manifest.readAsString()).trim()) ?? 0;
|
||||
if (total <= 0) return null;
|
||||
final done = Directory(dir)
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.contains('/seg_') && f.path.endsWith('.ts') && f.lengthSync() > 0)
|
||||
.length;
|
||||
if (done <= 0) return null;
|
||||
return (_IOSDownloadTask.downloadWeight * 100 * done / total).toStringAsFixed(2);
|
||||
} catch (e) {
|
||||
debugLog('[iOSDownload] 残留进度读取失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动下载
|
||||
/// 返回值与 Android 端 download 对齐:null=新任务,"正在执行"=已在跑,"已下载完成"=已完成,其它为错误文案
|
||||
Future<dynamic> download({required String url, DownloadCallback? callback}) async {
|
||||
if (url.isEmpty) return '视频链接为空';
|
||||
await _ensureInit();
|
||||
final outputPath = _outputFor(url);
|
||||
if (await File(outputPath).exists()) return '已下载完成';
|
||||
// key 剥掉 token/cdn,保证同一视频切线路/刷 token 后仍认作同一个任务
|
||||
final key = VideoDownloadManager.taskKey(url);
|
||||
if (_tasks.containsKey(key)) {
|
||||
if (callback != null) _tasks[key]!.addCallback(callback);
|
||||
return '正在执行';
|
||||
}
|
||||
|
||||
final task = _IOSDownloadTask(url: url, dir: _dirFor(url), outputPath: outputPath);
|
||||
if (callback != null) task.addCallback(callback);
|
||||
_tasks[key] = task;
|
||||
|
||||
// 不 await,后台跑
|
||||
task.start().whenComplete(() {
|
||||
_tasks.remove(key);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> delete(String url) async {
|
||||
await _ensureInit();
|
||||
try {
|
||||
await pause(url);
|
||||
final dir = Directory(_dirFor(url));
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 暂停:停掉正在跑的任务,但**保留已下分片**,下次 download 会接着下
|
||||
Future<void> pause(String url) async {
|
||||
final key = VideoDownloadManager.taskKey(url);
|
||||
final task = _tasks[key];
|
||||
if (task != null) {
|
||||
await task.cancel();
|
||||
_tasks.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// 只摘掉某个 url 上的某个回调(组件 dispose 时用)。
|
||||
/// 必须提供:回调注册在 _IOSDownloadTask 内部,manager 的 removeCallback 清不到这里
|
||||
void removeCallback(String url, DownloadCallback cb) {
|
||||
_tasks[VideoDownloadManager.taskKey(url)]?.removeCallback(cb);
|
||||
}
|
||||
|
||||
/// 清掉所有正在跑的任务的回调,但不停止下载
|
||||
/// 用途:widget dispose 时调用,防止后台任务还在 setState
|
||||
/// 任务本身继续跑,用户下次进页面再注册新回调
|
||||
void removeAllCallbacks() {
|
||||
for (final t in _tasks.values) {
|
||||
t._callbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emptyCache() async {
|
||||
await _ensureInit();
|
||||
for (final t in List.of(_tasks.values)) {
|
||||
await t.cancel();
|
||||
}
|
||||
_tasks.clear();
|
||||
try {
|
||||
final root = Directory(_basePath!);
|
||||
if (root.existsSync()) {
|
||||
await root.delete(recursive: true);
|
||||
await root.create();
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析出来的 m3u8
|
||||
class _Playlist {
|
||||
/// 分片的绝对 url
|
||||
final List<String> segments;
|
||||
|
||||
/// 每个分片的时长(秒),与 [segments] 一一对应;累加得总时长,用于算 remux 进度
|
||||
final List<double> durations;
|
||||
|
||||
/// AES-128 key 的绝对 url;未加密为空
|
||||
final String keyUrl;
|
||||
|
||||
/// #EXT-X-KEY 原始行;写 local.m3u8 时只把里面的 URI 换成本地 key,METHOD/IV 保持原样
|
||||
final String keyLine;
|
||||
final int mediaSequence;
|
||||
|
||||
_Playlist({
|
||||
required this.segments,
|
||||
required this.durations,
|
||||
required this.keyUrl,
|
||||
required this.keyLine,
|
||||
required this.mediaSequence,
|
||||
});
|
||||
|
||||
double get totalSeconds => durations.fold(0.0, (a, b) => a + b);
|
||||
}
|
||||
|
||||
class _IOSDownloadTask {
|
||||
final String url;
|
||||
final String dir;
|
||||
final String outputPath;
|
||||
final List<DownloadCallback> _callbacks = [];
|
||||
final CancelToken _cancelToken = CancelToken();
|
||||
|
||||
/// 分片下载用独立 Dio:项目的 httpManager.dio 挂了 HttpResponseInterceptor,
|
||||
/// 它会把响应硬解析成 BaseRespBean 并在失败时弹 toast —— 二进制分片走那条链路
|
||||
/// 既解析不了、几百个分片失败还会弹几百次 toast。这里只要超时和证书策略。
|
||||
final Dio _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
validateStatus: (int? status) => (status ?? 600) < 400,
|
||||
));
|
||||
|
||||
String currentProgress = '0.00';
|
||||
bool _cancelled = false;
|
||||
int? _sessionId;
|
||||
|
||||
/// 分片并发数;再高对服务端不友好,收益也有限
|
||||
static const _concurrency = 6;
|
||||
|
||||
/// 单个分片的重试次数:一次网络抖动不该让整条任务失败
|
||||
static const _maxRetry = 3;
|
||||
|
||||
/// 下载占 0~70%,remux 占 70~100%
|
||||
static const downloadWeight = 0.7;
|
||||
|
||||
_IOSDownloadTask({required this.url, required this.dir, required this.outputPath});
|
||||
|
||||
void addCallback(DownloadCallback cb) {
|
||||
_callbacks.remove(cb);
|
||||
_callbacks.add(cb);
|
||||
}
|
||||
|
||||
void removeCallback(DownloadCallback cb) => _callbacks.remove(cb);
|
||||
|
||||
Future<void> start() async {
|
||||
try {
|
||||
final d = Directory(dir);
|
||||
if (!d.existsSync()) await d.create(recursive: true);
|
||||
|
||||
debugLog('[iOSDownload] start url=$url');
|
||||
|
||||
final playlist = await _fetchPlaylist(url);
|
||||
if (playlist.segments.isEmpty) throw Exception('m3u8 里没有 ts 分片');
|
||||
debugLog('[iOSDownload] 分片数=${playlist.segments.length} 加密=${playlist.keyUrl.isNotEmpty}');
|
||||
// 记下总分片数,进程重启后 searchInfo 靠它还原暂停进度
|
||||
await File('$dir/segments.count').writeAsString('${playlist.segments.length}');
|
||||
|
||||
// key 只有一份,先拉;已存在则跳过(同样支持续传)
|
||||
String? keyPath;
|
||||
if (playlist.keyUrl.isNotEmpty) {
|
||||
keyPath = '$dir/sec.key';
|
||||
if (!await File(keyPath).exists()) {
|
||||
await _dio.download(playlist.keyUrl, keyPath, cancelToken: _cancelToken);
|
||||
}
|
||||
}
|
||||
|
||||
final localSegments = await _downloadSegments(playlist.segments);
|
||||
if (_cancelled) return; // 保留已下分片,下次接着下
|
||||
|
||||
// 落 local.m3u8:ts 指向本地 file://,KEY 的 URI 换成本地 sec.key 交给 ffmpeg 自行解密
|
||||
final m3u8Path = '$dir/local.m3u8';
|
||||
await File(m3u8Path).writeAsString(_buildLocalM3u8(playlist, localSegments, keyPath));
|
||||
|
||||
await _remux(m3u8Path, playlist.totalSeconds);
|
||||
if (_cancelled) {
|
||||
await _deleteOutput(); // 半成品 mp4 会被 searchInfo 误判为已完成,必须删;ts 留着
|
||||
return;
|
||||
}
|
||||
|
||||
// mp4 已生成,中间产物就没用了,删掉免得占双份空间
|
||||
await _cleanupIntermediates(localSegments, m3u8Path, keyPath);
|
||||
|
||||
currentProgress = '100.00';
|
||||
try {
|
||||
await ImageGallerySaver.saveFile(outputPath);
|
||||
debugLog('[iOSDownload] saved to gallery');
|
||||
} catch (e) {
|
||||
debugLog('[iOSDownload] 保存到相册失败: $e');
|
||||
}
|
||||
_notifySuccess();
|
||||
} catch (e, st) {
|
||||
if (_cancelled || (e is DioException && CancelToken.isCancel(e))) {
|
||||
debugLog('[iOSDownload] 已取消');
|
||||
await _deleteOutput();
|
||||
return;
|
||||
}
|
||||
debugLog('[iOSDownload] 失败: $e\n$st');
|
||||
// 只删半成品 mp4,**保留已下分片**:否则一次网络失败就把几百个分片清空,续传白做
|
||||
await _deleteOutput();
|
||||
_notifyFail(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉 m3u8 并解析;遇到 master playlist 就跟进第一个变体
|
||||
Future<_Playlist> _fetchPlaylist(String playlistUrl, {int depth = 0}) async {
|
||||
final resp = await _dio.get<String>(
|
||||
playlistUrl,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
cancelToken: _cancelToken,
|
||||
);
|
||||
final body = resp.data ?? '';
|
||||
final lines = const LineSplitter().convert(body);
|
||||
|
||||
// master playlist:#EXT-X-STREAM-INF 的下一行是变体地址,取第一个跟进去。
|
||||
// 老版本靠 ffmpeg 自动做这一步,自己解析就必须补上,否则解析不出任何分片
|
||||
if (depth == 0 && lines.any((l) => l.startsWith('#EXT-X-STREAM-INF'))) {
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue;
|
||||
for (var j = i + 1; j < lines.length; j++) {
|
||||
final v = lines[j].trim();
|
||||
if (v.isEmpty || v.startsWith('#')) continue;
|
||||
debugLog('[iOSDownload] master playlist,跟进变体: $v');
|
||||
return _fetchPlaylist(_resolve(playlistUrl, v), depth: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final segments = <String>[];
|
||||
final durations = <double>[];
|
||||
var keyUrl = '';
|
||||
var keyLine = '';
|
||||
var mediaSequence = 0;
|
||||
double pendingDuration = 0;
|
||||
|
||||
for (final raw in lines) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
mediaSequence = int.tryParse(line.split(':').last.trim()) ?? 0;
|
||||
} else if (line.startsWith('#EXT-X-KEY')) {
|
||||
keyLine = line;
|
||||
final m = RegExp(r'URI="([^"]*)"').firstMatch(line);
|
||||
if (m != null) keyUrl = _resolve(playlistUrl, m.group(1) ?? '');
|
||||
} else if (line.startsWith('#EXTINF:')) {
|
||||
final raw2 = line.substring('#EXTINF:'.length).split(',').first.trim();
|
||||
pendingDuration = double.tryParse(raw2) ?? 0;
|
||||
} else if (!line.startsWith('#')) {
|
||||
segments.add(_resolve(playlistUrl, line));
|
||||
durations.add(pendingDuration);
|
||||
pendingDuration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return _Playlist(
|
||||
segments: segments,
|
||||
durations: durations,
|
||||
keyUrl: keyUrl,
|
||||
keyLine: keyLine,
|
||||
mediaSequence: mediaSequence,
|
||||
);
|
||||
}
|
||||
|
||||
/// 相对路径转绝对(分片和 key 都可能是相对地址)
|
||||
String _resolve(String base, String ref) {
|
||||
if (ref.startsWith('http')) return ref;
|
||||
return Uri.parse(base).resolve(ref).toString();
|
||||
}
|
||||
|
||||
/// 并发下载分片,返回本地路径(顺序与入参一致)
|
||||
Future<List<String>> _downloadSegments(List<String> urls) async {
|
||||
final paths = List<String>.filled(urls.length, '');
|
||||
var done = 0;
|
||||
var next = 0;
|
||||
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
if (_cancelled) return;
|
||||
final i = next++; // Dart 单线程,自增不会被打断
|
||||
if (i >= urls.length) return;
|
||||
final path = '$dir/seg_$i.ts';
|
||||
// 断点续跑:已经下过的分片直接跳过
|
||||
final f = File(path);
|
||||
if (!f.existsSync() || f.lengthSync() == 0) {
|
||||
await _downloadWithRetry(urls[i], path);
|
||||
}
|
||||
paths[i] = path;
|
||||
done++;
|
||||
_setProgress(downloadWeight * done / urls.length);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(
|
||||
List.generate(urls.length < _concurrency ? urls.length : _concurrency, (_) => worker()),
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// 单分片下载带重试:网络抖动不该让整条任务失败(失败会连带丢掉本次已下的所有分片进度)
|
||||
Future<void> _downloadWithRetry(String segUrl, String path) async {
|
||||
for (var attempt = 1; attempt <= _maxRetry; attempt++) {
|
||||
if (_cancelled) return;
|
||||
try {
|
||||
await _dio.download(segUrl, path, cancelToken: _cancelToken);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (_cancelled || (e is DioException && CancelToken.isCancel(e))) rethrow;
|
||||
// 失败残留的空/半截文件要清掉,否则下次续传会把它当成已下好的分片跳过
|
||||
try {
|
||||
final f = File(path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
} catch (_) {}
|
||||
if (attempt == _maxRetry) rethrow;
|
||||
debugLog('[iOSDownload] 分片重试 $attempt/$_maxRetry: $segUrl');
|
||||
await Future.delayed(Duration(milliseconds: 300 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg 侧要能读到 ts 和 key:ts 写 file:// 绝对路径,KEY 行只替换 URI、保留 METHOD/IV
|
||||
/// (整行重写会丢 IV,带显式 IV 的流会因默认 IV(分片序号)解密错位)
|
||||
String _buildLocalM3u8(_Playlist playlist, List<String> localSegments, String? keyPath) {
|
||||
final b = StringBuffer()
|
||||
..writeln('#EXTM3U')
|
||||
..writeln('#EXT-X-VERSION:3')
|
||||
..writeln('#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence}');
|
||||
if (playlist.keyLine.isNotEmpty && keyPath != null) {
|
||||
b.writeln(playlist.keyLine.replaceFirst(RegExp(r'URI="[^"]*"'), 'URI="$keyPath"'));
|
||||
}
|
||||
for (var i = 0; i < localSegments.length; i++) {
|
||||
final d = i < playlist.durations.length ? playlist.durations[i] : 0.0;
|
||||
b
|
||||
..writeln('#EXTINF:$d,')
|
||||
..writeln('file://${localSegments[i]}');
|
||||
}
|
||||
b.writeln('#EXT-X-ENDLIST');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/// 本地 ts → mp4,`-c copy` 不重编码。
|
||||
/// 这段刻意不复用 VideoSaveUtil:那是 Android 存相册的路径,抽公共方法就会动到 Android
|
||||
Future<void> _remux(String m3u8Path, double totalSeconds) async {
|
||||
final totalMs = (totalSeconds * 1000).toInt();
|
||||
final command = '-y '
|
||||
'-allowed_extensions ALL '
|
||||
'-protocol_whitelist "file,http,https,tcp,tls,crypto" '
|
||||
'-i "$m3u8Path" '
|
||||
'-c copy -bsf:a aac_adtstoasc -movflags +faststart '
|
||||
'"$outputPath"';
|
||||
debugLog('[iOSDownload] remux cmd=$command');
|
||||
|
||||
final completer = Completer<void>();
|
||||
final session = await FFmpegKit.executeAsync(
|
||||
command,
|
||||
(s) async {
|
||||
final rc = await s.getReturnCode();
|
||||
if (!ReturnCode.isSuccess(rc) && !_cancelled) {
|
||||
final logs = await s.getAllLogsAsString();
|
||||
debugLog('[iOSDownload] remux 失败 rc=${rc?.getValue()} logs:\n$logs');
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(Exception('remux 失败: ${rc?.getValue()}'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
},
|
||||
(log) => debugLog('[ffmpeg] ${log.getMessage()}'),
|
||||
(Statistics stat) {
|
||||
if (_cancelled || totalMs <= 0) return;
|
||||
_setProgress(downloadWeight + (1 - downloadWeight) * (stat.getTime() / totalMs));
|
||||
},
|
||||
);
|
||||
_sessionId = session.getSessionId();
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
Future<void> _cleanupIntermediates(
|
||||
List<String> segments,
|
||||
String m3u8Path,
|
||||
String? keyPath,
|
||||
) async {
|
||||
for (final s in segments) {
|
||||
try {
|
||||
final f = File(s);
|
||||
if (f.existsSync()) await f.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final p in [m3u8Path, '$dir/segments.count', if (keyPath != null) keyPath]) {
|
||||
try {
|
||||
final f = File(p);
|
||||
if (f.existsSync()) await f.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只删半成品 mp4:它会被 searchInfo 误判成"已下载完成"。ts 分片一律保留给续传用
|
||||
Future<void> _deleteOutput() async {
|
||||
try {
|
||||
final f = File(outputPath);
|
||||
if (f.existsSync()) await f.delete();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
_cancelled = true;
|
||||
if (!_cancelToken.isCancelled) _cancelToken.cancel('用户取消');
|
||||
if (_sessionId != null) await FFmpegKit.cancel(_sessionId!);
|
||||
}
|
||||
|
||||
void _setProgress(double p) {
|
||||
if (p < 0) p = 0;
|
||||
if (p > 1) p = 1;
|
||||
currentProgress = (p * 100).toStringAsFixed(2);
|
||||
_notifyProgress(currentProgress);
|
||||
}
|
||||
|
||||
/// 逐个回调独立 try-catch:任一监听方抛异常(典型如已 dispose 的组件 setState)
|
||||
/// 都不能中断循环,否则排在它后面的监听方会永久收不到事件
|
||||
void _each(String tag, void Function(DownloadCallback cb) action) {
|
||||
for (final cb in List.of(_callbacks)) {
|
||||
try {
|
||||
action(cb);
|
||||
} catch (e) {
|
||||
debugLog('[iOSDownload] $tag 回调异常(已隔离): $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _notifySuccess() => _each('success', (cb) => cb.success?.call(url));
|
||||
|
||||
void _notifyFail(String error) => _each('fail', (cb) => cb.fail?.call(url, error));
|
||||
|
||||
void _notifyProgress(String progress) => _each('progress', (cb) => cb.progress?.call(url, progress));
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hgdj/alert/mine/vip_level_dialog.dart';
|
||||
import 'package:hgdj/alert/video/buy_vip_alert.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_video_player_logic.dart';
|
||||
import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:hgdj/hj_page/pre_sale/pre_sale_service.dart';
|
||||
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:hgdj/hj_utils/codec_support.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/net_code.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 下载状态(对应 VideoModel.isLoaderRunning 的 "0"/"1"/"2")
|
||||
enum DownloadState {
|
||||
idle('下载'),
|
||||
loading('下载中'),
|
||||
done('已下载');
|
||||
|
||||
const DownloadState(this.desc);
|
||||
|
||||
final String desc;
|
||||
}
|
||||
|
||||
/// 一条内容的下载会话:盯着哪条任务地址、进度回调挂在谁身上。
|
||||
/// 由 [MediaDownloadManager.attach] 创建,UI 拿到后只管在 onChanged 里重绘、点击时调 [start]
|
||||
class DownloadTask {
|
||||
DownloadTask._(this.video, this.style, this._onChanged);
|
||||
|
||||
// ===== 外部传入 =====
|
||||
final VideoModel? video;
|
||||
final MediaStyle style;
|
||||
final VoidCallback _onChanged;
|
||||
|
||||
// ===== 会话状态 =====
|
||||
/// 本会话盯着的下载地址。短剧的下载地址由授权接口现签,和播放地址不是同一条 path,
|
||||
/// 所以不能到处直接用 video.realVideoUrl
|
||||
String? taskUrl;
|
||||
DownloadCallback? _callback;
|
||||
bool _detached = false;
|
||||
bool _busy = false; // 本次点击还没走完,防连点
|
||||
|
||||
// ===== 派生 =====
|
||||
bool get _isDrama => style == MediaStyle.Drama;
|
||||
|
||||
String get _mediaId => video?.id ?? '';
|
||||
|
||||
String get _contentId => video?.subid ?? ''; // 短剧的分集 id 存在 subid,不是 video.id
|
||||
|
||||
DownloadState get state {
|
||||
if (video?.localPath?.isNotEmpty == true || video?.isLoaderRunning == "2")
|
||||
return DownloadState.done;
|
||||
if (video?.isDownloading == true) return DownloadState.loading;
|
||||
return DownloadState.idle;
|
||||
}
|
||||
|
||||
/// 回调匹配用 taskKey(剥掉 token/cdn):token 刷新或切线路后整条 url 会变,
|
||||
/// 拿整条 url 比对会匹配失败,进度条卡死不再刷新(下载其实仍按 path 继续跑)
|
||||
bool _isMine(String url) =>
|
||||
VideoDownloadManager.taskKey(taskUrl) ==
|
||||
VideoDownloadManager.taskKey(url);
|
||||
|
||||
/// 点下载。连点必须挡住:两次并发会各自生成一个幂等键(第一次还没落盘第二次就读到了空表),
|
||||
/// 服务端当成两次下载扣两次次数;长视频那条 reduceDownloadCount 同理
|
||||
Future<void> start() async {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await MediaDownloadManager.instance._start(this);
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 只摘本会话自己的回调,不能清光单例里的全部回调——那会连别的组件/页面的一起清掉
|
||||
void detach() {
|
||||
_detached = true;
|
||||
final cb = _callback;
|
||||
if (cb != null) VideoDownloadManager.instance.removeCallback(taskUrl, cb);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载业务层:能不能下(VIP/次数/短剧权益)、下哪条地址、扣不扣次、记录落哪个桶,
|
||||
/// 连同引导弹窗和提示全收在这里,UI 只负责画状态和转发点击。
|
||||
/// 底层任务(原生 m3u8 下载器、进度事件分发)仍归 [VideoDownloadManager],两层别混
|
||||
class MediaDownloadManager {
|
||||
MediaDownloadManager._();
|
||||
|
||||
static final instance = MediaDownloadManager._();
|
||||
|
||||
// ===== 会话 =====
|
||||
|
||||
/// 挂上进度回调并回填这条内容当前的缓存状态。同步返回句柄,绑定在后台走完——
|
||||
/// UI 在 initState 里拿到就能持有,dispose 时不用等
|
||||
DownloadTask attach(
|
||||
{required VideoModel? video,
|
||||
required MediaStyle style,
|
||||
required VoidCallback onChanged}) {
|
||||
final task = DownloadTask._(video, style, onChanged);
|
||||
unawaited(_bind(task));
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<void> _bind(DownloadTask task) async {
|
||||
final video = task.video;
|
||||
//短剧下载的是授权接口现签的那条地址,只按播放地址查会认不出「已经下过」,
|
||||
//得先从缓存记录里把当时那条捞回来
|
||||
final resolved = (task._isDrama
|
||||
? (await VideoCacheStore.instance.find(MediaStyle.Drama, video))
|
||||
?.realVideoUrl
|
||||
: null) ??
|
||||
video?.realVideoUrl;
|
||||
if (task._detached) return;
|
||||
//用 ??=:查记录这一路是异步的,用户抢先点了下载的话地址已被 _enqueue 定过,别再顶回去
|
||||
task.taskUrl ??= resolved;
|
||||
task._callback = DownloadCallback(
|
||||
success: (url) {
|
||||
if (task._isMine(url)) {
|
||||
video?.isLoaderRunning = "2";
|
||||
video?.loadProgress = "100.00";
|
||||
}
|
||||
task._onChanged();
|
||||
},
|
||||
fail: (url, _) {
|
||||
if (task._isMine(url)) video?.isLoaderRunning = "0";
|
||||
showToast("缓存加载失败");
|
||||
task._onChanged();
|
||||
},
|
||||
progress: (url, progress) {
|
||||
if (!task._isMine(url)) return;
|
||||
video?.isLoaderRunning = "1";
|
||||
video?.loadProgress = progress;
|
||||
task._onChanged();
|
||||
},
|
||||
);
|
||||
final info = await VideoDownloadManager.instance
|
||||
.searchInfo(url: task.taskUrl, callback: task._callback);
|
||||
if (task._detached) {
|
||||
task.detach(); // searchInfo 顺手挂上的回调,走到这里已经没人要了
|
||||
return;
|
||||
}
|
||||
if (info == null) return;
|
||||
if (info.localPath?.isNotEmpty == true) {
|
||||
video?.localPath = info.localPath;
|
||||
} else if (info.isDownloading) {
|
||||
video?.isLoaderRunning = "1";
|
||||
video?.loadProgress = info.progress;
|
||||
}
|
||||
task._onChanged();
|
||||
}
|
||||
|
||||
// ===== 点下载 =====
|
||||
|
||||
/// 点下载:鉴权/扣次/引导全在这里走完,成功就把任务交给 [VideoDownloadManager]
|
||||
Future<void> _start(DownloadTask task) async {
|
||||
//在下/下完的先回话再说,别让它去走鉴权:否则下完的视频点一下还要刷钱包、
|
||||
//不是会员还会弹一个「开通VIP」,最后才告诉人家早就下好了
|
||||
switch (task.state) {
|
||||
case DownloadState.loading:
|
||||
showToast("正在下载中...");
|
||||
return;
|
||||
case DownloadState.done:
|
||||
showToast("已下载完成");
|
||||
return;
|
||||
case DownloadState.idle:
|
||||
break;
|
||||
}
|
||||
//短剧另有一套:权限、次数、扣次全在授权接口里一次做完,不走下面这条通用链路
|
||||
if (task._isDrama) {
|
||||
await _startDrama(task);
|
||||
return;
|
||||
}
|
||||
final video = task.video;
|
||||
if (video?.isFreeDownload == true) {
|
||||
await _enqueue(task);
|
||||
return;
|
||||
}
|
||||
// 预售特权:还有今日下载次数就消耗一次特权,消耗失败不下载
|
||||
if (presaleProvider.isOpen &&
|
||||
presaleProvider.hasLimit == true &&
|
||||
(presaleProvider.remain?.todayDownloadCount ?? 0) > 0) {
|
||||
final resp =
|
||||
await PreSaleService.consumePrivilege(type: PrivilegeType.download);
|
||||
if (resp.isSuccess) {
|
||||
presaleProvider.reduceDownload();
|
||||
await _enqueue(task);
|
||||
}
|
||||
return;
|
||||
}
|
||||
//钱包不在 attach 时刷:短视频列表每个 item 都有下载按钮,滑动会创建大量会话,那就是刷一路钱包接口。
|
||||
//点下载时刷一次,次数 gating 仍拿最新值
|
||||
await globalStore.refreshWallet();
|
||||
if (!globalStore.isVIP) {
|
||||
showVipLevelDialog("下载视频需要开通VIP会员\n\n开通会员 即可享会员专属特权");
|
||||
return;
|
||||
}
|
||||
if (longVideoStatus(video).isNeedBuy) {
|
||||
showToast("您未购买当前视频,无法使用下载功能");
|
||||
return;
|
||||
}
|
||||
if ((globalStore.wallet?.downloadCount ?? 0) == 0) {
|
||||
showToast("今日下载次数已使用完,明日再来");
|
||||
return;
|
||||
}
|
||||
if (await MineService.reduceDownloadCount()) {
|
||||
globalStore.refreshWallet();
|
||||
await _enqueue(task);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 短剧 =====
|
||||
|
||||
/// 短剧下载:登录态、上下架、短剧权益、下载次数校验和扣 1 次全在授权接口里一次做完,
|
||||
/// 客户端不再自己判 VIP/次数,也不能调 `/mine/download/use`——那个只扣次不给资源,会重复扣
|
||||
Future<void> _startDrama(DownloadTask task) async {
|
||||
//这一集还没解锁的先去开卡,省一次注定失败的授权请求
|
||||
if (task.video?.dramaEpisode?.canPlay != true) {
|
||||
await _openDramaCard(task);
|
||||
return;
|
||||
}
|
||||
if (task._mediaId.isEmpty || task._contentId.isEmpty) {
|
||||
showToast("剧集信息不完整");
|
||||
return;
|
||||
}
|
||||
await _authorize(task, allowRetry: true);
|
||||
}
|
||||
|
||||
/// 授权 → 拿地址 → 开下载。
|
||||
/// [allowRetry] 只留给 5003(扣次结果不确定)自动补问一次,且必须复用同一个 requestId
|
||||
Future<void> _authorize(DownloadTask task, {required bool allowRetry}) async {
|
||||
//幂等键先落盘再发请求:响应丢了/超时重试都得拿同一个键回来,换新键服务端会再扣一次次数
|
||||
final requestId =
|
||||
await VideoCacheStore.instance.dramaRequestId(task._contentId);
|
||||
final resp = await DramaService.authorizeDownload(
|
||||
mediaId: task._mediaId,
|
||||
contentId: task._contentId,
|
||||
requestId: requestId,
|
||||
);
|
||||
final auth = resp.data;
|
||||
if (resp.isSuccess && auth is DramaDownloadAuth) {
|
||||
//次数在服务端已经扣掉了,哪怕这会儿用户已经划走也得把任务开起来,否则钱花了没下成
|
||||
await _onAuthorized(task, auth);
|
||||
return;
|
||||
}
|
||||
if (task._detached) return; // 失败的引导弹窗没必要追着已经离开的人弹
|
||||
await _onAuthorizeFailed(task, resp, allowRetry: allowRetry);
|
||||
}
|
||||
|
||||
/// 授权通过:剩余次数以服务端回的为准(别本地 -1,幂等重试那次压根没扣),
|
||||
/// 地址按设备解码能力二选一
|
||||
Future<void> _onAuthorized(DownloadTask task, DramaDownloadAuth auth) async {
|
||||
globalStore.wallet?.downloadCount = auth.remainingDownloadCount;
|
||||
globalStore.refreshWallet();
|
||||
final h265 = auth.h265DownloadUrl ?? '';
|
||||
final source = CodecSupport.useH265 && h265.isNotEmpty
|
||||
? h265
|
||||
: (auth.downloadUrl ?? '');
|
||||
if (source.isEmpty) {
|
||||
showToast("资源异常,请稍后再试"); // 两条地址都空,别建一个下不动的空任务
|
||||
return;
|
||||
}
|
||||
//落记录用副本:授权地址只属于这次下载,写回在播的那个 model 会顶掉播放地址
|
||||
final record = VideoModel.fromJson(task.video?.toJson())
|
||||
..sourceURL = source
|
||||
..h265Url = '';
|
||||
await _enqueue(task, url: record.realVideoUrl, record: record);
|
||||
}
|
||||
|
||||
/// 授权失败分流。有响应的失败网络层已经按 tip 弹过提示,这里只做要额外引导/善后的几种
|
||||
Future<void> _onAuthorizeFailed(DownloadTask task, BaseRespBean resp,
|
||||
{required bool allowRetry}) async {
|
||||
final data = resp.data;
|
||||
switch (resp.code) {
|
||||
//1000 既是封号也是「没有短剧权益」,只有 data.reason 能区分,封号那种交给网络层的提示
|
||||
case Code.ACCOUNT_INVISIBLE
|
||||
when data is Map && data['reason'] == 'DRAMA_ENTITLEMENT_REQUIRED':
|
||||
await _openDramaCard(task);
|
||||
//次数不足:引导买带下载次数的商品
|
||||
case Code.NOT_ENOUGH_DOWNLOAD:
|
||||
globalStore.refreshWallet();
|
||||
showVipLevelDialog("下载次数已用完\n\n开通会员 即可获取下载次数");
|
||||
//剧集已下架/参数不对:本地那份过期了,重拉分集详情,别原地重试
|
||||
case Code.PARAM_INVALID:
|
||||
await _refreshEpisode(task);
|
||||
//同一个键被用到别的剧集上了:清掉脏映射,下次点算全新的一次下载
|
||||
case Code.REPLAY_ATTACK:
|
||||
await VideoCacheStore.instance.dropDramaRequestId(task._contentId);
|
||||
//扣次结果不确定:拿同一个键再问一次,问出来是成功就直接给地址,不会重复扣
|
||||
case Code.CHARGE_UNCERTAIN:
|
||||
if (allowRetry) await _authorize(task, allowRetry: false);
|
||||
//断网/超时这类没响应的,网络层不弹提示,自己兜一句;幂等键留着等下次重试复用
|
||||
case Code.NETWORK_ERROR || Code.NETWORK_TIMEOUT || Code.LOCAL_NO_NETWORK:
|
||||
showToast(resp.toast);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未解锁的短剧集:走付费墙同一个开卡弹窗,默认选中 ping 下发的短剧卡。
|
||||
/// 关掉后重拉一次分集详情,服务端放行了再点下载就能下
|
||||
Future<void> _openDramaCard(DownloadTask task) async {
|
||||
final video = task.video;
|
||||
final mediaId = video?.dramaInfo?.id;
|
||||
final episode = video?.dramaEpisode;
|
||||
await BuyVipAlert.show(
|
||||
vipId: Config.shortDramaCardId,
|
||||
orderTrack: PayOrderTrackInfo(
|
||||
sourcePage: PaySourcePage.dramaPaywall,
|
||||
sourceRef: mediaId,
|
||||
videoId: mediaId,
|
||||
mediaId: mediaId,
|
||||
contentId: episode?.id,
|
||||
checkoutContextId: episode?.paywall?.checkoutContextId,
|
||||
),
|
||||
);
|
||||
await _refreshEpisode(task);
|
||||
//开卡放行了要叫醒播放器:下载这条链自己不碰播放器,不广播的话买完卡付费墙还杵在原地
|
||||
if (task.video?.dramaEpisode?.canPlay == true)
|
||||
DramaVideoPlayerLogic.broadcastUnlock();
|
||||
}
|
||||
|
||||
/// 重拉分集详情:本地那份的解锁状态/地址可能已经过期
|
||||
Future<void> _refreshEpisode(DownloadTask task) async {
|
||||
final fresh = await DramaService.fetchEpisode(task.video?.subid);
|
||||
if (fresh != null) task.video?.dramaEpisode = fresh;
|
||||
task._onChanged();
|
||||
}
|
||||
|
||||
// ===== 交给底层 =====
|
||||
|
||||
/// [url] 本次真正要下的地址(短剧走授权接口现签的那条),不传就用播放地址;
|
||||
/// [record] 落进缓存库的记录,不传就用当前 model
|
||||
Future<void> _enqueue(DownloadTask task,
|
||||
{String? url, VideoModel? record}) async {
|
||||
//拿不到权限也照旧下(部分机型无需存储权限),所以不看结果
|
||||
final status = await Permission.storage.status;
|
||||
if (!status.isGranted) await Permission.storage.request();
|
||||
final video = task.video;
|
||||
final target = url ?? video?.realVideoUrl ?? "";
|
||||
//换地址要把回调从老 key 上摘掉,否则那条既收不到事件、detach 也清不掉
|
||||
final cb = task._callback;
|
||||
if (cb != null &&
|
||||
VideoDownloadManager.taskKey(target) !=
|
||||
VideoDownloadManager.taskKey(task.taskUrl)) {
|
||||
VideoDownloadManager.instance.removeCallback(task.taskUrl, cb);
|
||||
}
|
||||
task.taskUrl = target;
|
||||
//返回值是 VideoDownloadManager.download 的约定:null=新任务开起来了,两个中文串=已有任务,其余是错误文案
|
||||
switch (await VideoDownloadManager.instance
|
||||
.download(url: target, callback: cb)) {
|
||||
case null:
|
||||
VideoCacheStore.instance.saveVideoInfo(task.style, record ?? video);
|
||||
video?.isLoaderRunning = "1";
|
||||
showToast("开始下载...");
|
||||
case "正在执行":
|
||||
video?.isLoaderRunning = "1";
|
||||
showToast("正在下载中...");
|
||||
case "已下载完成了":
|
||||
video?.isLoaderRunning = "2";
|
||||
showToast("已下载完成");
|
||||
case final err:
|
||||
showToast(err.toString());
|
||||
}
|
||||
task._onChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../hj_utils/const.dart';
|
||||
import '../../hj_utils/light_model.dart';
|
||||
import '../../hj_utils/store_keys.dart';
|
||||
|
||||
/// 下载缓存记录的本地存储(lightKV),按 [MediaStyle] 分影视 / 短视频 / 动漫 / 短剧四个 key 存放。
|
||||
/// 只管记录的增删查与每日下载次数,实际下载任务见 [VideoDownloadManager]
|
||||
class VideoCacheStore {
|
||||
// 工厂方法构造函数
|
||||
factory VideoCacheStore() => _getInstance();
|
||||
|
||||
static VideoCacheStore get instance => _getInstance();
|
||||
|
||||
// 静态变量_instance,存储唯一对象
|
||||
static VideoCacheStore? _instance;
|
||||
|
||||
VideoCacheStore._internal();
|
||||
|
||||
// 获取对象
|
||||
static VideoCacheStore _getInstance() {
|
||||
_instance ??= VideoCacheStore._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 有独立存储桶的业务;[_listKey] 未列到的一律落影视桶
|
||||
static const _buckets = [
|
||||
MediaStyle.Video,
|
||||
MediaStyle.ShortVideo,
|
||||
MediaStyle.Cartoon,
|
||||
MediaStyle.Drama
|
||||
];
|
||||
|
||||
/// 各业务的存储 key,未列到的一律落影视桶
|
||||
static String _listKey(MediaStyle type) => switch (type) {
|
||||
MediaStyle.ShortVideo => StoreKeys.SHORT_CACHE_LIST,
|
||||
MediaStyle.Cartoon => StoreKeys.CARTOON_CACHE_LIST,
|
||||
MediaStyle.Drama => StoreKeys.DRAMA_CACHE_LIST,
|
||||
_ => StoreKeys.MOVIE_CACHE_LIST,
|
||||
};
|
||||
|
||||
/// 记录去重/删除时的身份。
|
||||
/// 短剧一部剧下多集,而 [VideoModel.id] 存的是**剧** id、分集 id 在 subid,
|
||||
/// 只比 id 会让第二集把第一集的记录挤掉,所以短剧必须带上 subid。
|
||||
/// 其余业务维持原样只认 id(动漫虽然也有 subid,但它一条记录就是一部作品)
|
||||
static String _identity(MediaStyle type, VideoModel video) =>
|
||||
type == MediaStyle.Drama
|
||||
? '${video.id}#${video.subid}'
|
||||
: (video.id ?? '');
|
||||
|
||||
/// 缓存记录
|
||||
Future<List<VideoModel>> getMovieCacheVideoList(MediaStyle type) async {
|
||||
final listString = await lightKV.getStringList(_listKey(type)) ?? [];
|
||||
return listString.map((e) => VideoModel.fromJson(json.decode(e))).toList();
|
||||
}
|
||||
|
||||
Future<int> getVideoLoadCount() async {
|
||||
String todayKey = DateTimeUtil.utc3YearMonthDay(DateTime.now());
|
||||
var localStr = await lightKV.getString(StoreKeys.MOVIE_CACHE_COUNT) ?? "";
|
||||
if (localStr.isEmpty) return 0;
|
||||
var jsonMap = json.decode(localStr);
|
||||
if (jsonMap[todayKey] != null && jsonMap[todayKey] is int) {
|
||||
return jsonMap[todayKey];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
saveVideoLoadCount(int count) async {
|
||||
try {
|
||||
String todayKey = DateTimeUtil.utc3YearMonthDay(DateTime.now());
|
||||
if (todayKey.isEmpty) return;
|
||||
Map<String, int> countMap = {todayKey: count};
|
||||
String mapString = json.encode(countMap);
|
||||
await lightKV.setString(StoreKeys.MOVIE_CACHE_COUNT, mapString);
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isExistLoadVideoByUrl(MediaStyle type, String url) async {
|
||||
final videoArr = await getMovieCacheVideoList(type);
|
||||
return videoArr.any((model) => model.sourceURL == url);
|
||||
}
|
||||
|
||||
/// 落一条缓存记录,新的排最前;同身份的旧记录被顶掉
|
||||
Future<void> saveVideoInfo(MediaStyle type, VideoModel? video) async {
|
||||
if (video == null) return;
|
||||
final id = _identity(type, video);
|
||||
final rest = (await getMovieCacheVideoList(type))
|
||||
.where((e) => _identity(type, e) != id);
|
||||
await _write(type, [video, ...rest]);
|
||||
}
|
||||
|
||||
Future<void> removeVideo(MediaStyle type, VideoModel? video) async {
|
||||
if (video == null) return;
|
||||
await removeVideoList(type, [video]);
|
||||
}
|
||||
|
||||
Future<void> removeVideoList(MediaStyle type, List<VideoModel> videos) async {
|
||||
final ids = videos.map((e) => _identity(type, e)).toSet();
|
||||
final rest = (await getMovieCacheVideoList(type))
|
||||
.where((e) => !ids.contains(_identity(type, e)));
|
||||
await _write(type, rest);
|
||||
//删了就是这次下载结束了,幂等键跟着清;留着的话重下会命中上一次的授权(不扣次也拿不到新地址)
|
||||
if (type == MediaStyle.Drama)
|
||||
await _dropDramaRequestIds(videos.map((e) => e.subid));
|
||||
}
|
||||
|
||||
/// 按身份取一条已存的记录。短剧下载地址是授权接口现签的,和播放地址不是同一条 path,
|
||||
/// 重进播放页只按播放地址查会认不出"已经下过",得先用它把当时那条地址捞回来
|
||||
Future<VideoModel?> find(MediaStyle type, VideoModel? video) async {
|
||||
if (video == null) return null;
|
||||
final id = _identity(type, video);
|
||||
for (final e in await getMovieCacheVideoList(type)) {
|
||||
if (_identity(type, e) == id) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 短剧下载幂等键 =====
|
||||
|
||||
/// 这一集下载用的 `X-Request-ID`,没有就新建并**先落盘再返回**——
|
||||
/// 授权请求超时/响应丢了都得拿同一个键重试,换新键服务端会再扣一次下载次数
|
||||
Future<String> dramaRequestId(String contentId) async {
|
||||
final map = await _dramaRequestIds();
|
||||
final exist = map[contentId];
|
||||
if (exist is String && exist.isNotEmpty) return exist;
|
||||
final id = const Uuid().v4();
|
||||
map[contentId] = id;
|
||||
await _writeDramaRequestIds(map);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 丢弃这一集的幂等键:下次点下载算新的一次,服务端会正常扣次。
|
||||
/// 4009(同一个键换了剧集)也走它把脏映射清掉
|
||||
Future<void> dropDramaRequestId(String? contentId) =>
|
||||
_dropDramaRequestIds([contentId]);
|
||||
|
||||
Future<void> _dropDramaRequestIds(Iterable<String?> contentIds) async {
|
||||
final map = await _dramaRequestIds();
|
||||
if (map.isEmpty) return;
|
||||
map.removeWhere((k, _) => contentIds.contains(k));
|
||||
await _writeDramaRequestIds(map);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _dramaRequestIds() async {
|
||||
final str =
|
||||
await lightKV.getString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID) ?? '';
|
||||
if (str.isEmpty) return {};
|
||||
try {
|
||||
return Map<String, dynamic>.from(json.decode(str));
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeDramaRequestIds(Map<String, dynamic> map) =>
|
||||
lightKV.setString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID, json.encode(map));
|
||||
|
||||
/// 不确定记录落在哪个桶时逐个桶删(缓存页多选删除用)
|
||||
Future<void> removeVideoListNoType(List<VideoModel> videos) async {
|
||||
for (final type in _buckets) {
|
||||
await removeVideoList(type, videos);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空全部桶的缓存记录(磁盘文件由 [VideoDownloadManager.emptyCache] 删)。
|
||||
/// 不能只清影视桶——文件是整个目录删掉的,剩下的桶会留一堆指向已删文件的死记录
|
||||
Future<void> removeAll() async {
|
||||
await lightKV.setString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID, '');
|
||||
for (final type in _buckets) {
|
||||
await _write(type, const []);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _write(MediaStyle type, Iterable<VideoModel> videos) =>
|
||||
lightKV.setStringList(
|
||||
_listKey(type), videos.map((e) => json.encode(e.toJson())).toList());
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:hgdj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:m3u8_downloader/m3u8_downloader.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../debug_log.dart';
|
||||
import 'ios_video_downloader.dart';
|
||||
|
||||
/// 下载回调,每个 url 可注册多个
|
||||
/// - success(url)
|
||||
/// - fail(url, errorMsg)
|
||||
/// - progress(url, progressStr) — progress 形如 "12.34",完成时为 "100.00"
|
||||
class DownloadCallback {
|
||||
final Function(String)? success;
|
||||
final Function(String, String)? fail;
|
||||
final Function(String, String)? progress;
|
||||
|
||||
DownloadCallback({this.success, this.fail, this.progress});
|
||||
}
|
||||
|
||||
/// m3u8 视频下载/缓存管理,按平台转发到两套完全不同的实现:
|
||||
/// - Android:调三方插件 M3u8Downloader,下载在原生侧执行,产物是本地 m3u8 + ts 分片。
|
||||
/// 原生通过 CallbackHandle 在 Dart 后台 isolate 里执行本文件底部那几个顶层回调,
|
||||
/// 回调再用 IsolateNameServer 找到主 isolate 的 [_port] 把事件发回来,
|
||||
/// 最后分发给已注册的 [DownloadCallback]。要存相册需再经 VideoSaveUtil 转 mp4。
|
||||
/// - iOS:转发到 [IOSVideoDownloader],ffmpeg 边下边转,产物直接是 mp4,下载完成即自动写入相册。
|
||||
class VideoDownloadManager {
|
||||
// ===== 单例 =====
|
||||
|
||||
factory VideoDownloadManager() => instance;
|
||||
|
||||
static VideoDownloadManager get instance =>
|
||||
_instance ??= VideoDownloadManager._();
|
||||
static VideoDownloadManager? _instance;
|
||||
|
||||
// ===== 状态 =====
|
||||
|
||||
/// 缓存根目录,[init] 完成后才可用;跨平台取值走 [cacheDir]
|
||||
static late String _basePath;
|
||||
|
||||
static bool _isInited = false;
|
||||
|
||||
final ReceivePort _port = ReceivePort();
|
||||
|
||||
/// url(taskKey) → 该 url 的回调列表(成功/失败/进度全部走这里分发)
|
||||
final Map<String, List<DownloadCallback>> _callbacks = {};
|
||||
|
||||
/// 下载任务的稳定标识:只取 m3u8 的 path(/vid/h5/m3u8/xxx),剥掉 token / c(cdn) 这些易变 query。
|
||||
/// realVideoUrl 是用全局 Address.token / Address.cdnAddress 现拼的,token 刷新或用户切线路后整条 url 就变了。
|
||||
/// 拿整条 url 当回调 key / 匹配条件,会导致原生回传的老 url 和现拼的新 url 对不上——
|
||||
/// 进度回调匹配失败,进度条卡在切换那一刻的百分比不再刷新(下载其实还在按 path 继续跑)。
|
||||
static String taskKey(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
return Uri.tryParse(url)?.path ?? url;
|
||||
}
|
||||
|
||||
VideoDownloadManager._() {
|
||||
// Dart 后台 isolate(由原生触发)通过 IsolateNameServer 找到这个 port 把下载事件发回来。
|
||||
// 必须先 remove 再 register:registerPortWithName 遇到同名已注册会直接返回 false、不覆盖。
|
||||
// 热重启后旧端口(已随旧 isolate 失效)仍占着这个名字,不先清掉的话新 _port 注册失败,
|
||||
// 后台 isolate lookup 到的是旧死端口 → send 进黑洞 → 实时进度回调全丢(要退出重进靠 searchInfo 才看到)。
|
||||
IsolateNameServer.removePortNameMapping(_portName);
|
||||
IsolateNameServer.registerPortWithName(_port.sendPort, _portName);
|
||||
_port.listen(_onIsolateEvent);
|
||||
}
|
||||
|
||||
/// 后台 isolate 回传的下载事件:按 taskKey 找到监听方逐个分发
|
||||
void _onIsolateEvent(dynamic data) {
|
||||
if (data is! Map) return;
|
||||
debugLog("isolate message:$data");
|
||||
final String url = data["url"];
|
||||
final key = taskKey(url);
|
||||
final list = _callbacks[key] ?? const <DownloadCallback>[];
|
||||
|
||||
// 逐个回调独立 try-catch:任一监听方抛异常(典型如已 dispose 的组件 setState)
|
||||
// 都不能中断循环,否则排在它后面的监听方永久收不到事件
|
||||
void notify(String type, void Function(DownloadCallback cb) action) {
|
||||
for (final cb in List.of(list)) {
|
||||
try {
|
||||
action(cb);
|
||||
} catch (e) {
|
||||
debugLog('download $type 回调异常(已隔离): $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (data["status"]) {
|
||||
case 0: // 缓存失败
|
||||
notify('fail', (cb) => cb.fail?.call(url, data["error"]));
|
||||
_callbacks.remove(key);
|
||||
case 1: // 缓存成功
|
||||
notify('success', (cb) => cb.success?.call(url));
|
||||
_callbacks.remove(key);
|
||||
case 2: // 进度
|
||||
final raw = data["progress"];
|
||||
final progress =
|
||||
raw is double ? raw.toStringAsFixed(2) : raw.toString();
|
||||
notify('progress', (cb) => cb.progress?.call(url, progress));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 初始化 / 目录 =====
|
||||
|
||||
/// 初始化原生下载器(Android 专用)
|
||||
/// iOS 端走 [IOSVideoDownloader],不需要走这里
|
||||
Future<bool> init() async {
|
||||
if (Platform.isIOS) return false;
|
||||
if (_isInited) return true;
|
||||
final dir = Platform.isAndroid
|
||||
? await getExternalStorageDirectory()
|
||||
: await getApplicationDocumentsDirectory();
|
||||
_basePath = '${dir!.path}/vPlayDownload';
|
||||
final root = Directory(_basePath);
|
||||
if (!root.existsSync()) await root.create();
|
||||
debugLog(_basePath);
|
||||
// onSelect 返回 null 表示采用默认清晰度,不弹选择框
|
||||
_isInited = await M3u8Downloader.initialize(onSelect: () async => null);
|
||||
if (_isInited) {
|
||||
_isInited = await M3u8Downloader.config(
|
||||
saveDir: _basePath,
|
||||
progressCallback: _onProgress,
|
||||
successCallback: _onSuccess,
|
||||
errorCallback: _onError,
|
||||
);
|
||||
}
|
||||
return _isInited;
|
||||
}
|
||||
|
||||
/// 已下载视频缓存根目录(跨平台安全获取)
|
||||
/// iOS 的 _basePath 不在 [init] 里赋值(直接 return false),直接读 static late 字段会抛 LateInitializationError,
|
||||
/// 统计缓存大小时必须走这里
|
||||
Future<String> cacheDir() async {
|
||||
if (Platform.isIOS) return IOSVideoDownloader.instance.baseDir();
|
||||
if (!_isInited) await init();
|
||||
return _basePath;
|
||||
}
|
||||
|
||||
/// 清空所有缓存:删除磁盘文件 + 清空数据库记录 + 清空回调
|
||||
Future<void> emptyCache() async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
await IOSVideoDownloader.instance.emptyCache();
|
||||
} else {
|
||||
// 走 cacheDir 确保 _basePath 已初始化,避免未下载过就清理时读 late 字段抛 LateInitializationError
|
||||
final root = Directory(await cacheDir());
|
||||
if (root.existsSync()) await root.delete(recursive: true);
|
||||
if (!root.existsSync()) await root.create();
|
||||
}
|
||||
await VideoCacheStore.instance.removeAll();
|
||||
_clearCallbacks();
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 任务 =====
|
||||
|
||||
/// 查询 url 当前缓存状态,两端的裸 Map 统一解析成 [DownloadInfo]
|
||||
/// 返回 null 表示:url 为空 / 未下载过 / 缓存初始化失败
|
||||
/// [callback] 仅在查到任务正在下载时挂上,用于后续接收进度回调
|
||||
Future<DownloadInfo?> searchInfo(
|
||||
{String? url, DownloadCallback? callback}) async {
|
||||
if (Platform.isIOS) {
|
||||
final ret = await IOSVideoDownloader.instance
|
||||
.searchInfo(url ?? '', callback: callback);
|
||||
return ret is Map ? DownloadInfo.fromJson(ret) : null;
|
||||
}
|
||||
if (!_isInited && !await init()) {
|
||||
debugLog("缓存初始化失败");
|
||||
return null;
|
||||
}
|
||||
if (url?.isNotEmpty != true) return null;
|
||||
final ret = await M3u8Downloader.searchInfo(url!);
|
||||
debugLog("======= result:$ret");
|
||||
if (ret is! Map) return null;
|
||||
final info = DownloadInfo.fromJson(ret);
|
||||
if (info.isDownloading && callback != null) _addCallback(url, callback);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 开始下载
|
||||
/// 返回值:
|
||||
/// - null / "正在执行": 任务已开启或已在跑,会自动挂上 [callback]
|
||||
/// - 其它 String: 错误提示,如 "缓存加载失败" / "视频链接为空" / 异常文案
|
||||
Future<dynamic> download(
|
||||
{required String url, DownloadCallback? callback}) async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
return IOSVideoDownloader.instance
|
||||
.download(url: url, callback: callback);
|
||||
}
|
||||
if (!_isInited && !await init()) return "缓存加载失败";
|
||||
if (url.isEmpty) return "视频链接为空";
|
||||
final result = await M3u8Downloader.download(url: url, name: "m3u8");
|
||||
if ((result == null || result == "正在执行") && callback != null) {
|
||||
_addCallback(url, callback);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 暂停指定 url 的下载任务,并清掉其回调,避免暂停后还收到进度
|
||||
Future<void> pause(String url) async {
|
||||
if (Platform.isIOS) {
|
||||
await IOSVideoDownloader.instance.pause(url);
|
||||
} else {
|
||||
await M3u8Downloader.pause(url);
|
||||
}
|
||||
_callbacks.remove(taskKey(url));
|
||||
}
|
||||
|
||||
/// 删除指定 url 的缓存文件,同时清掉它的回调
|
||||
Future<bool> delete(String url) async {
|
||||
final ret = Platform.isIOS
|
||||
? await IOSVideoDownloader.instance.delete(url)
|
||||
: await M3u8Downloader.delete(url);
|
||||
_callbacks.remove(taskKey(url));
|
||||
return ret;
|
||||
}
|
||||
|
||||
// ===== 回调注册 =====
|
||||
|
||||
/// 注册回调;先 remove 再 add 是为了**防止同一 callback 重复注册**导致一次事件触发多次
|
||||
void _addCallback(String url, DownloadCallback callback) {
|
||||
final list = _callbacks.putIfAbsent(taskKey(url), () => []);
|
||||
list.remove(callback);
|
||||
list.add(callback);
|
||||
}
|
||||
|
||||
/// 只移除某 url 上的某个回调(组件 dispose 时用,避免清掉别的组件/页面的回调)
|
||||
/// iOS 的回调注册在 [_IOSDownloadTask] 内部而非本类的 _callbacks,必须转发过去,
|
||||
/// 否则组件 dispose 后回调仍残留在任务里 → 被调到就抛 setState after dispose,
|
||||
/// 还会掐断同一任务上排在它后面的回调(缓存页进度就此静止)
|
||||
void removeCallback(String? url, DownloadCallback callback) {
|
||||
if (Platform.isIOS) {
|
||||
IOSVideoDownloader.instance.removeCallback(url ?? '', callback);
|
||||
return;
|
||||
}
|
||||
final key = taskKey(url);
|
||||
final list = _callbacks[key];
|
||||
if (list == null) return;
|
||||
list.remove(callback);
|
||||
if (list.isEmpty) _callbacks.remove(key);
|
||||
}
|
||||
|
||||
//只在 emptyCache 里用:清光全部监听方。别在页面/组件里调,那会连别处的回调一起清掉
|
||||
void _clearCallbacks() {
|
||||
_callbacks.clear();
|
||||
if (Platform.isIOS) IOSVideoDownloader.instance.removeAllCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
// ==== 以下顶层函数在 Dart 后台 isolate 中执行(原生侧经 CallbackHandle 触发),必须是顶层/静态,不能放进类里 ====
|
||||
// 都要标 @pragma('vm:entry-point'):后台 isolate 通过 getCallbackFromHandle 取它们,
|
||||
// release AOT 下不标会被 tree-shake,导致回调拿不到、进度/成功事件丢失。
|
||||
|
||||
const _portName = "downloader_send_port";
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
_onProgress(dynamic args) {
|
||||
final port = IsolateNameServer.lookupPortByName(_portName);
|
||||
if (port == null) return;
|
||||
args["status"] = 2;
|
||||
port.send(args);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
_onSuccess(dynamic args) {
|
||||
debugLog("=======load success!!!!!!");
|
||||
debugLog(args);
|
||||
IsolateNameServer.lookupPortByName(_portName)?.send({
|
||||
"status": 1,
|
||||
"url": args["url"],
|
||||
"filePath": args["filePath"],
|
||||
"dir": args["dir"]
|
||||
});
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
_onError(dynamic args) {
|
||||
IsolateNameServer.lookupPortByName(_portName)
|
||||
?.send({"status": 0, "url": args["url"]});
|
||||
}
|
||||
|
||||
/// [VideoDownloadManager.searchInfo] 的查询结果:某个 url 当前的下载状态。
|
||||
/// 两端(Android 插件 / iOS ffmpeg)回的都是裸 Map,统一在这里收口,调用方别再按字符串取键
|
||||
class DownloadInfo {
|
||||
String? localPath; // 本地路径(下载完才有)
|
||||
String? progress; // 进度百分比字符串,完成为 "100.00"
|
||||
String? isLoaderRunning; // "1" 下载中 / "0" 已暂停;已完成时不下发
|
||||
String? status; // "2" 下载完成;下载中时由 isLoaderRunning 推出 "1"
|
||||
|
||||
DownloadInfo(
|
||||
{this.progress, this.localPath, this.status, this.isLoaderRunning});
|
||||
|
||||
DownloadInfo.fromJson(Map json) {
|
||||
localPath = json['localPath'];
|
||||
progress = json['progress'];
|
||||
isLoaderRunning = json['isLoaderRunning'];
|
||||
if (json['isLoaderRunning'] == "1") {
|
||||
status = '1';
|
||||
}
|
||||
if (json['status'] != null) {
|
||||
status = json['status'];
|
||||
}
|
||||
}
|
||||
|
||||
//下载任务是否正在跑
|
||||
bool get isDownloading => isLoaderRunning == "1";
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/statistics.dart';
|
||||
import 'package:hgdj/hj_utils/image_util.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:image_gallery_saver/image_gallery_saver.dart';
|
||||
|
||||
import '../debug_log.dart';
|
||||
import 'video_download_manager.dart';
|
||||
|
||||
/// 把**已下载完成**的视频导出到系统相册(不负责下载,下载见 [VideoDownloadManager])
|
||||
/// - Android:本地 m3u8 + ts 用 ffmpeg 转 mp4(不重编码),再存相册
|
||||
/// - iOS:下载产物本身就是 mp4,直接存相册
|
||||
/// 同一时刻只转一个,其余任务排队([_taskList] / [_queueList])
|
||||
class VideoSaveUtil {
|
||||
VideoSaveUtil._();
|
||||
static final VideoSaveUtil instance = VideoSaveUtil._();
|
||||
|
||||
final List<String> _taskList = [];
|
||||
final List<String> _queueList = [];
|
||||
|
||||
/// 视频转换成 mp4 保存到本地相册中
|
||||
/// 在 VideoDownloadManager 下载完成后调用
|
||||
Future<void> convertVideoMp4(String url,
|
||||
{DownloadInfo? loadInfo, bool isShowLoading = true}) async {
|
||||
// iOS 下载产物本身就是 mp4(ffmpeg 下载时已转码),localPath 直接指向 mp4,
|
||||
// 不能走下面的 m3u8 转码逻辑(会把 mp4 当 m3u8 文本读),直接保存即可
|
||||
if (Platform.isIOS) {
|
||||
await _saveIosMp4(url, loadInfo: loadInfo, isShowLoading: isShowLoading);
|
||||
return;
|
||||
}
|
||||
if (_taskList.contains(url)) {
|
||||
showToast("视频正在保存中...");
|
||||
return;
|
||||
}
|
||||
if (_taskList.isNotEmpty) {
|
||||
_queueList.add(url);
|
||||
showToast("视频正在处理中,请耐心等待...");
|
||||
return;
|
||||
}
|
||||
if (isShowLoading) {
|
||||
LoadingAlertWidget.show(canCancel: true);
|
||||
}
|
||||
try {
|
||||
//队列里补跑的任务(_consumeQueue)不带 loadInfo,得自己查一次
|
||||
final localFileInfo =
|
||||
loadInfo ?? await VideoDownloadManager.instance.searchInfo(url: url);
|
||||
if (localFileInfo?.localPath?.isNotEmpty != true) {
|
||||
showToast("视频文件不存在");
|
||||
if (isShowLoading) LoadingAlertWidget.cancel();
|
||||
return;
|
||||
}
|
||||
_taskList.add(url);
|
||||
final m3u8Path = File(localFileInfo!.localPath!);
|
||||
final dirPath = m3u8Path.parent.path;
|
||||
final outputPath = '$dirPath/download.mp4';
|
||||
|
||||
// 之前转好的 mp4 还在就不用再转一遍,直接存相册
|
||||
if (await File(outputPath).exists()) {
|
||||
await _saveToAlbum(outputPath);
|
||||
if (isShowLoading) LoadingAlertWidget.cancel();
|
||||
_taskList.remove(url);
|
||||
_consumeQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
final m3u8ContentList = await m3u8Path.readAsLines();
|
||||
final keyLineIndex =
|
||||
m3u8ContentList.indexWhere((e) => e.startsWith('#EXT-X-KEY'));
|
||||
// local.m3u8 里 ts 是本地文件,但 KEY 的 URI 仍是远程地址:把 key 拉到本地,
|
||||
// 再把 m3u8 的 KEY 行改成指向本地 sec.key,交给 ffmpeg 自行解密(不重编码)
|
||||
if (keyLineIndex >= 0) {
|
||||
final keyUrl =
|
||||
await _resolveKeyUrl(dirPath, m3u8ContentList[keyLineIndex], url);
|
||||
if (keyUrl.isEmpty) throw "密钥地址解析失败";
|
||||
final secKeyPath = '$dirPath/sec.key';
|
||||
if (!await _prepareKeyFile(keyUrl, secKeyPath)) throw "密钥获取失败,请重试";
|
||||
// 只替换 KEY 行里的 URI,保留 METHOD/IV 等其它属性。
|
||||
// 整行重写会丢掉 IV,带显式 IV 的流会因默认 IV(分片序号)解密错位。
|
||||
// 连到下一个逗号为止整段换掉:老数据的 URI 可能被原生插件多包了一层引号
|
||||
m3u8ContentList[keyLineIndex] = m3u8ContentList[keyLineIndex]
|
||||
.replaceFirst(RegExp(r'URI=[^,]*'), 'URI="$secKeyPath"');
|
||||
}
|
||||
final localM3u8File = File('$dirPath/download.m3u8');
|
||||
await localM3u8File.writeAsString(m3u8ContentList.join('\n'));
|
||||
|
||||
// 转换进度要的总时长直接累加 m3u8 的 EXTINF:省一次 VideoPlayer 初始化+释放,
|
||||
// 也不会因为播放器在老机型上初始化失败把整个保存流程带崩
|
||||
final totalDurationMs = _totalDurationMs(m3u8ContentList);
|
||||
|
||||
final command =
|
||||
'-allowed_extensions ALL -i "${localM3u8File.path}" -c copy -bsf:a aac_adtstoasc -movflags +faststart "$outputPath"';
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
(session) async {
|
||||
_taskList.remove(url);
|
||||
// --- 完成回调 ---
|
||||
if (isShowLoading) {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
final returnCode = await session.getReturnCode();
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
debugLog('转换成功,文件路径:$outputPath');
|
||||
await _saveToAlbum(outputPath);
|
||||
} else {
|
||||
try {
|
||||
await File(outputPath).delete();
|
||||
} catch (_) {}
|
||||
showToast("保存失败");
|
||||
debugLog('转换失败,返回码:${returnCode?.getValue()}',
|
||||
await session.getFailStackTrace());
|
||||
}
|
||||
_consumeQueue();
|
||||
},
|
||||
(log) => debugLog(log.getMessage()),
|
||||
(Statistics statistics) {
|
||||
// --- 统计回调:上报转换进度 ---
|
||||
if (totalDurationMs <= 0) return;
|
||||
double percentage = statistics.getTime() / totalDurationMs;
|
||||
if (percentage < 0) percentage = 0;
|
||||
if (percentage > 1) percentage = 1;
|
||||
if (isShowLoading) {
|
||||
LoadingAlertWidget.showExchangeTitle(
|
||||
"视频转换中: ${(percentage * 100).toStringAsFixed(1)}%",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_taskList.remove(url);
|
||||
if (isShowLoading) {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
debugLog('转换失败:$e');
|
||||
showToast(e is String ? e : "保存失败");
|
||||
_consumeQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS:下载完成的文件已是 mp4,直接保存到相册
|
||||
Future<void> _saveIosMp4(String url,
|
||||
{DownloadInfo? loadInfo, bool isShowLoading = true}) async {
|
||||
String? mp4Path = loadInfo?.localPath;
|
||||
if (mp4Path?.isNotEmpty != true) {
|
||||
mp4Path =
|
||||
(await VideoDownloadManager.instance.searchInfo(url: url))?.localPath;
|
||||
}
|
||||
if (mp4Path?.isNotEmpty != true || !await File(mp4Path!).exists()) {
|
||||
showToast("视频文件不存在");
|
||||
return;
|
||||
}
|
||||
if (isShowLoading) LoadingAlertWidget.show();
|
||||
await _saveToAlbum(mp4Path);
|
||||
if (isShowLoading) LoadingAlertWidget.cancel();
|
||||
}
|
||||
|
||||
/// 写入相册。插件失败时只返回 isSuccess=false 并不抛异常,
|
||||
/// 不看返回值会把「没存进去」报成保存成功
|
||||
Future<void> _saveToAlbum(String path) async {
|
||||
try {
|
||||
if (!await ImageUtil.requestAlbumPermission()) {
|
||||
showToast("请先开启相册权限");
|
||||
return;
|
||||
}
|
||||
//插件在个别机型上拿不到回调会一直挂着,给个兜底(大视频拷贝慢,给足 60s)
|
||||
final result = await ImageGallerySaver.saveFile(path).timeout(
|
||||
const Duration(seconds: 60),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (result is Map && result["isSuccess"] == true) {
|
||||
showToast("视频已保存到相册");
|
||||
return;
|
||||
}
|
||||
debugLog('写入相册失败', result);
|
||||
} catch (e) {
|
||||
debugLog('写入相册异常', e);
|
||||
}
|
||||
showToast("保存到相册失败");
|
||||
}
|
||||
|
||||
/// 取 key 的真实下载地址。
|
||||
/// local.m3u8 的 KEY 行是原生插件重新拼的,两种情况会拼坏:绝对地址会多包一层引号
|
||||
/// (取到空串)、带 query 的地址会被 '=' 截断(取到半截地址),所以优先从同目录的
|
||||
/// remote.m3u8(下载时存下的原始 m3u8 全文)里取原始 URI,相对地址再按视频地址补全
|
||||
Future<String> _resolveKeyUrl(
|
||||
String dirPath, String localKeyLine, String videoUrl) async {
|
||||
String raw = '';
|
||||
final remoteFile = File('$dirPath/remote.m3u8');
|
||||
if (await remoteFile.exists()) {
|
||||
final keyLine = (await remoteFile.readAsLines()).firstWhere(
|
||||
(e) => e.startsWith('#EXT-X-KEY'),
|
||||
orElse: () => '',
|
||||
);
|
||||
raw = RegExp(r'URI="([^"]*)"').firstMatch(keyLine)?.group(1) ?? '';
|
||||
}
|
||||
//remote.m3u8 丢了才退回 local 的 KEY 行,多余的引号一并去掉
|
||||
if (raw.isEmpty) {
|
||||
raw = (RegExp(r'URI=([^,]*)').firstMatch(localKeyLine)?.group(1) ?? '')
|
||||
.replaceAll('"', '');
|
||||
}
|
||||
if (raw.isEmpty) return '';
|
||||
try {
|
||||
return Uri.parse(videoUrl).resolve(raw).toString();
|
||||
} catch (e) {
|
||||
debugLog('key 地址补全失败:$raw', e);
|
||||
return raw.startsWith('http') ? raw : '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保本地有可用的 key 文件。AES-128 的 key 固定 16 字节,
|
||||
/// 大小不对说明上次拉到的是错误页/半截文件,重下一次;再不对就判失败,
|
||||
/// 别拿错 key 去解密——ffmpeg copy 不校验内容,会产出一个能存进相册但花屏的 mp4
|
||||
Future<bool> _prepareKeyFile(String keyUrl, String path) async {
|
||||
final keyFile = File(path);
|
||||
if (await keyFile.exists() && await keyFile.length() == 16) return true;
|
||||
try {
|
||||
await Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
)).download(keyUrl, path);
|
||||
} catch (e) {
|
||||
debugLog('下载 key 失败:$keyUrl', e);
|
||||
return false;
|
||||
}
|
||||
if (await keyFile.exists() && await keyFile.length() == 16) return true;
|
||||
debugLog('key 内容异常:$keyUrl');
|
||||
try {
|
||||
await keyFile.delete();
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// m3u8 里所有 EXTINF 之和(毫秒),用于算转换进度
|
||||
int _totalDurationMs(List<String> lines) {
|
||||
double seconds = 0;
|
||||
for (final line in lines) {
|
||||
if (!line.startsWith('#EXTINF:')) continue;
|
||||
seconds +=
|
||||
double.tryParse(line.substring(8).split(',').first.trim()) ?? 0;
|
||||
}
|
||||
return (seconds * 1000).round();
|
||||
}
|
||||
|
||||
/// 消费 _queueList 中下一个待转换任务
|
||||
void _consumeQueue() {
|
||||
if (_queueList.isEmpty) return;
|
||||
final next = _queueList.removeAt(0);
|
||||
convertVideoMp4(next);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user