初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -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 换成本地 keyMETHOD/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.m3u8ts 指向本地 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 和 keyts 写 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 再 registerregisterPortWithName 遇到同名已注册会直接返回 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);
}
}