logo和名称替换
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_model/video_model.dart';
|
||||
import 'package:hgmj/hj_utils/const.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/video_download/media_download_manager.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_model/video_model.dart';
|
||||
import 'package:wydj/hj_utils/const.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/video_download/media_download_manager.dart';
|
||||
|
||||
/// 视频下载按钮,短视频 / 长视频 / 短剧 / 动漫共用。
|
||||
/// 只管画状态和转发点击;权限、扣次、落记录、进度回调全在 [MediaDownloadManager]
|
||||
@@ -17,7 +17,8 @@ class DownloadButton extends StatefulWidget {
|
||||
/// 短剧在操作台里排版同短视频,但要单独归类,所以显式传 [MediaStyle.Drama]
|
||||
final MediaStyle? style;
|
||||
|
||||
const DownloadButton({super.key, this.video, this.isShort = true, this.style});
|
||||
const DownloadButton(
|
||||
{super.key, this.video, this.isShort = true, this.style});
|
||||
|
||||
@override
|
||||
State<DownloadButton> createState() => _DownloadButtonState();
|
||||
@@ -45,7 +46,8 @@ class _DownloadButtonState extends State<DownloadButton> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_task = MediaDownloadManager.instance.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
_task = MediaDownloadManager.instance
|
||||
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -55,7 +57,8 @@ class _DownloadButtonState extends State<DownloadButton> {
|
||||
//点下载下的也是上一条。同一个对象(父级只是重建)就别白折腾
|
||||
if (identical(oldWidget.video, widget.video)) return;
|
||||
_task.detach();
|
||||
_task = MediaDownloadManager.instance.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
_task = MediaDownloadManager.instance
|
||||
.attach(video: widget.video, style: _mediaStyle, onChanged: _refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,7 +83,10 @@ class _DownloadButtonState extends State<DownloadButton> {
|
||||
final label = Text(
|
||||
_task.state.desc,
|
||||
style: isShort
|
||||
? const TextStyle(color: Color(0xffdcdcdc), fontSize: 12, fontWeight: FontWeight.w500)
|
||||
? const TextStyle(
|
||||
color: Color(0xffdcdcdc),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)
|
||||
: const TextStyle(color: Color(0xff989898), fontSize: 12),
|
||||
);
|
||||
return InkWell(
|
||||
@@ -88,7 +94,9 @@ class _DownloadButtonState extends State<DownloadButton> {
|
||||
onTap: _task.start,
|
||||
child: isShort
|
||||
? Column(mainAxisSize: MainAxisSize.min, children: [icon, label])
|
||||
: Row(mainAxisSize: MainAxisSize.min, children: [icon, 2.sizeBoxW, label]),
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [icon, 2.sizeBoxW, label]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hgmj/alert/mine/vip_level_dialog.dart';
|
||||
import 'package:hgmj/alert/video/buy_vip_alert.dart';
|
||||
import 'package:hgmj/config/config.dart';
|
||||
import 'package:hgmj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgmj/hj_model/video_model.dart';
|
||||
import 'package:hgmj/hj_page/drama/view/drama_video_player_logic.dart';
|
||||
import 'package:hgmj/hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import 'package:hgmj/hj_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:hgmj/hj_page/pre_sale/pre_sale_service.dart';
|
||||
import 'package:hgmj/hj_page/video/view/long_video_status.dart';
|
||||
import 'package:hgmj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgmj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:hgmj/hj_utils/codec_support.dart';
|
||||
import 'package:hgmj/hj_utils/const.dart';
|
||||
import 'package:hgmj/tools_base/global_store/store.dart';
|
||||
import 'package:hgmj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgmj/tools_base/net/net_code.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:hgmj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:hgmj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:wydj/alert/mine/vip_level_dialog.dart';
|
||||
import 'package:wydj/alert/video/buy_vip_alert.dart';
|
||||
import 'package:wydj/config/config.dart';
|
||||
import 'package:wydj/hj_model/drama/drama_models.dart';
|
||||
import 'package:wydj/hj_model/video_model.dart';
|
||||
import 'package:wydj/hj_page/drama/view/drama_video_player_logic.dart';
|
||||
import 'package:wydj/hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import 'package:wydj/hj_page/pre_sale/pre_sale_provider.dart';
|
||||
import 'package:wydj/hj_page/pre_sale/pre_sale_service.dart';
|
||||
import 'package:wydj/hj_page/video/view/long_video_status.dart';
|
||||
import 'package:wydj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:wydj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:wydj/hj_utils/codec_support.dart';
|
||||
import 'package:wydj/hj_utils/const.dart';
|
||||
import 'package:wydj/tools_base/global_store/store.dart';
|
||||
import 'package:wydj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:wydj/tools_base/net/net_code.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
import 'package:wydj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:wydj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 下载状态(对应 VideoModel.isLoaderRunning 的 "0"/"1"/"2")
|
||||
@@ -60,14 +60,17 @@ class DownloadTask {
|
||||
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?.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);
|
||||
bool _isMine(String url) =>
|
||||
VideoDownloadManager.taskKey(taskUrl) ==
|
||||
VideoDownloadManager.taskKey(url);
|
||||
|
||||
/// 点下载。连点必须挡住:两次并发会各自生成一个幂等键(第一次还没落盘第二次就读到了空表),
|
||||
/// 服务端当成两次下载扣两次次数;长视频那条 reduceDownloadCount 同理
|
||||
@@ -101,7 +104,10 @@ class MediaDownloadManager {
|
||||
|
||||
/// 挂上进度回调并回填这条内容当前的缓存状态。同步返回句柄,绑定在后台走完——
|
||||
/// UI 在 initState 里拿到就能持有,dispose 时不用等
|
||||
DownloadTask attach({required VideoModel? video, required MediaStyle style, required VoidCallback onChanged}) {
|
||||
DownloadTask attach(
|
||||
{required VideoModel? video,
|
||||
required MediaStyle style,
|
||||
required VoidCallback onChanged}) {
|
||||
final task = DownloadTask._(video, style, onChanged);
|
||||
unawaited(_bind(task));
|
||||
return task;
|
||||
@@ -111,8 +117,11 @@ class MediaDownloadManager {
|
||||
final video = task.video;
|
||||
//短剧下载的是授权接口现签的那条地址,只按播放地址查会认不出「已经下过」,
|
||||
//得先从缓存记录里把当时那条捞回来
|
||||
final resolved =
|
||||
(task._isDrama ? (await VideoCacheStore.instance.find(MediaStyle.Drama, video))?.realVideoUrl : null) ?? video?.realVideoUrl;
|
||||
final resolved = (task._isDrama
|
||||
? (await VideoCacheStore.instance.find(MediaStyle.Drama, video))
|
||||
?.realVideoUrl
|
||||
: null) ??
|
||||
video?.realVideoUrl;
|
||||
if (task._detached) return;
|
||||
//用 ??=:查记录这一路是异步的,用户抢先点了下载的话地址已被 _enqueue 定过,别再顶回去
|
||||
task.taskUrl ??= resolved;
|
||||
@@ -136,7 +145,8 @@ class MediaDownloadManager {
|
||||
task._onChanged();
|
||||
},
|
||||
);
|
||||
final info = await VideoDownloadManager.instance.searchInfo(url: task.taskUrl, callback: task._callback);
|
||||
final info = await VideoDownloadManager.instance
|
||||
.searchInfo(url: task.taskUrl, callback: task._callback);
|
||||
if (task._detached) {
|
||||
task.detach(); // searchInfo 顺手挂上的回调,走到这里已经没人要了
|
||||
return;
|
||||
@@ -178,8 +188,11 @@ class MediaDownloadManager {
|
||||
return;
|
||||
}
|
||||
// 预售特权:还有今日下载次数就消耗一次特权,消耗失败不下载
|
||||
if (presaleProvider.isOpen && presaleProvider.hasLimit == true && (presaleProvider.remain?.todayDownloadCount ?? 0) > 0) {
|
||||
final resp = await PreSaleService.consumePrivilege(type: PrivilegeType.download);
|
||||
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);
|
||||
@@ -228,7 +241,8 @@ class MediaDownloadManager {
|
||||
/// [allowRetry] 只留给 5003(扣次结果不确定)自动补问一次,且必须复用同一个 requestId
|
||||
Future<void> _authorize(DownloadTask task, {required bool allowRetry}) async {
|
||||
//幂等键先落盘再发请求:响应丢了/超时重试都得拿同一个键回来,换新键服务端会再扣一次次数
|
||||
final requestId = await VideoCacheStore.instance.dramaRequestId(task._contentId);
|
||||
final requestId =
|
||||
await VideoCacheStore.instance.dramaRequestId(task._contentId);
|
||||
final resp = await DramaService.authorizeDownload(
|
||||
mediaId: task._mediaId,
|
||||
contentId: task._contentId,
|
||||
@@ -250,7 +264,9 @@ class MediaDownloadManager {
|
||||
globalStore.wallet?.downloadCount = auth.remainingDownloadCount;
|
||||
globalStore.refreshWallet();
|
||||
final h265 = auth.h265DownloadUrl ?? '';
|
||||
final source = CodecSupport.useH265 && h265.isNotEmpty ? h265 : (auth.downloadUrl ?? '');
|
||||
final source = CodecSupport.useH265 && h265.isNotEmpty
|
||||
? h265
|
||||
: (auth.downloadUrl ?? '');
|
||||
if (source.isEmpty) {
|
||||
showToast("资源异常,请稍后再试"); // 两条地址都空,别建一个下不动的空任务
|
||||
return;
|
||||
@@ -263,11 +279,13 @@ class MediaDownloadManager {
|
||||
}
|
||||
|
||||
/// 授权失败分流。有响应的失败网络层已经按 tip 弹过提示,这里只做要额外引导/善后的几种
|
||||
Future<void> _onAuthorizeFailed(DownloadTask task, BaseRespBean resp, {required bool allowRetry}) async {
|
||||
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':
|
||||
case Code.ACCOUNT_INVISIBLE
|
||||
when data is Map && data['reason'] == 'DRAMA_ENTITLEMENT_REQUIRED':
|
||||
await _openDramaCard(task);
|
||||
//次数不足:引导买带下载次数的商品
|
||||
case Code.NOT_ENOUGH_DOWNLOAD:
|
||||
@@ -307,7 +325,8 @@ class MediaDownloadManager {
|
||||
);
|
||||
await _refreshEpisode(task);
|
||||
//开卡放行了要叫醒播放器:下载这条链自己不碰播放器,不广播的话买完卡付费墙还杵在原地
|
||||
if (task.video?.dramaEpisode?.canPlay == true) DramaVideoPlayerLogic.broadcastUnlock();
|
||||
if (task.video?.dramaEpisode?.canPlay == true)
|
||||
DramaVideoPlayerLogic.broadcastUnlock();
|
||||
}
|
||||
|
||||
/// 重拉分集详情:本地那份的解锁状态/地址可能已经过期
|
||||
@@ -321,7 +340,8 @@ class MediaDownloadManager {
|
||||
|
||||
/// [url] 本次真正要下的地址(短剧走授权接口现签的那条),不传就用播放地址;
|
||||
/// [record] 落进缓存库的记录,不传就用当前 model
|
||||
Future<void> _enqueue(DownloadTask task, {String? url, VideoModel? record}) async {
|
||||
Future<void> _enqueue(DownloadTask task,
|
||||
{String? url, VideoModel? record}) async {
|
||||
//拿不到权限也照旧下(部分机型无需存储权限),所以不看结果
|
||||
final status = await Permission.storage.status;
|
||||
if (!status.isGranted) await Permission.storage.request();
|
||||
@@ -329,12 +349,15 @@ class MediaDownloadManager {
|
||||
final target = url ?? video?.realVideoUrl ?? "";
|
||||
//换地址要把回调从老 key 上摘掉,否则那条既收不到事件、detach 也清不掉
|
||||
final cb = task._callback;
|
||||
if (cb != null && VideoDownloadManager.taskKey(target) != VideoDownloadManager.taskKey(task.taskUrl)) {
|
||||
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)) {
|
||||
switch (await VideoDownloadManager.instance
|
||||
.download(url: target, callback: cb)) {
|
||||
case null:
|
||||
VideoCacheStore.instance.saveVideoInfo(task.style, record ?? video);
|
||||
video?.isLoaderRunning = "1";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgmj/hj_model/video_model.dart';
|
||||
import 'package:hgmj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/hj_model/video_model.dart';
|
||||
import 'package:wydj/hj_utils/date_time_util.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../hj_utils/const.dart';
|
||||
@@ -29,7 +29,12 @@ class VideoCacheStore {
|
||||
}
|
||||
|
||||
/// 有独立存储桶的业务;[_listKey] 未列到的一律落影视桶
|
||||
static const _buckets = [MediaStyle.Video, MediaStyle.ShortVideo, MediaStyle.Cartoon, MediaStyle.Drama];
|
||||
static const _buckets = [
|
||||
MediaStyle.Video,
|
||||
MediaStyle.ShortVideo,
|
||||
MediaStyle.Cartoon,
|
||||
MediaStyle.Drama
|
||||
];
|
||||
|
||||
/// 各业务的存储 key,未列到的一律落影视桶
|
||||
static String _listKey(MediaStyle type) => switch (type) {
|
||||
@@ -44,7 +49,9 @@ class VideoCacheStore {
|
||||
/// 只比 id 会让第二集把第一集的记录挤掉,所以短剧必须带上 subid。
|
||||
/// 其余业务维持原样只认 id(动漫虽然也有 subid,但它一条记录就是一部作品)
|
||||
static String _identity(MediaStyle type, VideoModel video) =>
|
||||
type == MediaStyle.Drama ? '${video.id}#${video.subid}' : (video.id ?? '');
|
||||
type == MediaStyle.Drama
|
||||
? '${video.id}#${video.subid}'
|
||||
: (video.id ?? '');
|
||||
|
||||
/// 缓存记录
|
||||
Future<List<VideoModel>> getMovieCacheVideoList(MediaStyle type) async {
|
||||
@@ -84,7 +91,8 @@ class VideoCacheStore {
|
||||
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);
|
||||
final rest = (await getMovieCacheVideoList(type))
|
||||
.where((e) => _identity(type, e) != id);
|
||||
await _write(type, [video, ...rest]);
|
||||
}
|
||||
|
||||
@@ -95,10 +103,12 @@ class VideoCacheStore {
|
||||
|
||||
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)));
|
||||
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));
|
||||
if (type == MediaStyle.Drama)
|
||||
await _dropDramaRequestIds(videos.map((e) => e.subid));
|
||||
}
|
||||
|
||||
/// 按身份取一条已存的记录。短剧下载地址是授权接口现签的,和播放地址不是同一条 path,
|
||||
@@ -128,7 +138,8 @@ class VideoCacheStore {
|
||||
|
||||
/// 丢弃这一集的幂等键:下次点下载算新的一次,服务端会正常扣次。
|
||||
/// 4009(同一个键换了剧集)也走它把脏映射清掉
|
||||
Future<void> dropDramaRequestId(String? contentId) => _dropDramaRequestIds([contentId]);
|
||||
Future<void> dropDramaRequestId(String? contentId) =>
|
||||
_dropDramaRequestIds([contentId]);
|
||||
|
||||
Future<void> _dropDramaRequestIds(Iterable<String?> contentIds) async {
|
||||
final map = await _dramaRequestIds();
|
||||
@@ -138,7 +149,8 @@ class VideoCacheStore {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _dramaRequestIds() async {
|
||||
final str = await lightKV.getString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID) ?? '';
|
||||
final str =
|
||||
await lightKV.getString(StoreKeys.DRAMA_DOWNLOAD_REQUEST_ID) ?? '';
|
||||
if (str.isEmpty) return {};
|
||||
try {
|
||||
return Map<String, dynamic>.from(json.decode(str));
|
||||
@@ -168,5 +180,6 @@ class VideoCacheStore {
|
||||
}
|
||||
|
||||
Future<void> _write(MediaStyle type, Iterable<VideoModel> videos) =>
|
||||
lightKV.setStringList(_listKey(type), videos.map((e) => json.encode(e.toJson())).toList());
|
||||
lightKV.setStringList(
|
||||
_listKey(type), videos.map((e) => json.encode(e.toJson())).toList());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:hgmj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:wydj/tools_base/video_download/video_cache_store.dart';
|
||||
import 'package:m3u8_downloader/m3u8_downloader.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -32,7 +32,8 @@ class VideoDownloadManager {
|
||||
|
||||
factory VideoDownloadManager() => instance;
|
||||
|
||||
static VideoDownloadManager get instance => _instance ??= VideoDownloadManager._();
|
||||
static VideoDownloadManager get instance =>
|
||||
_instance ??= VideoDownloadManager._();
|
||||
static VideoDownloadManager? _instance;
|
||||
|
||||
// ===== 状态 =====
|
||||
@@ -95,7 +96,8 @@ class VideoDownloadManager {
|
||||
_callbacks.remove(key);
|
||||
case 2: // 进度
|
||||
final raw = data["progress"];
|
||||
final progress = raw is double ? raw.toStringAsFixed(2) : raw.toString();
|
||||
final progress =
|
||||
raw is double ? raw.toStringAsFixed(2) : raw.toString();
|
||||
notify('progress', (cb) => cb.progress?.call(url, progress));
|
||||
}
|
||||
}
|
||||
@@ -107,7 +109,9 @@ class VideoDownloadManager {
|
||||
Future<bool> init() async {
|
||||
if (Platform.isIOS) return false;
|
||||
if (_isInited) return true;
|
||||
final dir = Platform.isAndroid ? await getExternalStorageDirectory() : await getApplicationDocumentsDirectory();
|
||||
final dir = Platform.isAndroid
|
||||
? await getExternalStorageDirectory()
|
||||
: await getApplicationDocumentsDirectory();
|
||||
_basePath = '${dir!.path}/vPlayDownload';
|
||||
final root = Directory(_basePath);
|
||||
if (!root.existsSync()) await root.create();
|
||||
@@ -157,9 +161,11 @@ class VideoDownloadManager {
|
||||
/// 查询 url 当前缓存状态,两端的裸 Map 统一解析成 [DownloadInfo]
|
||||
/// 返回 null 表示:url 为空 / 未下载过 / 缓存初始化失败
|
||||
/// [callback] 仅在查到任务正在下载时挂上,用于后续接收进度回调
|
||||
Future<DownloadInfo?> searchInfo({String? url, DownloadCallback? callback}) async {
|
||||
Future<DownloadInfo?> searchInfo(
|
||||
{String? url, DownloadCallback? callback}) async {
|
||||
if (Platform.isIOS) {
|
||||
final ret = await IOSVideoDownloader.instance.searchInfo(url ?? '', callback: callback);
|
||||
final ret = await IOSVideoDownloader.instance
|
||||
.searchInfo(url ?? '', callback: callback);
|
||||
return ret is Map ? DownloadInfo.fromJson(ret) : null;
|
||||
}
|
||||
if (!_isInited && !await init()) {
|
||||
@@ -179,10 +185,12 @@ class VideoDownloadManager {
|
||||
/// 返回值:
|
||||
/// - null / "正在执行": 任务已开启或已在跑,会自动挂上 [callback]
|
||||
/// - 其它 String: 错误提示,如 "缓存加载失败" / "视频链接为空" / 异常文案
|
||||
Future<dynamic> download({required String url, DownloadCallback? callback}) async {
|
||||
Future<dynamic> download(
|
||||
{required String url, DownloadCallback? callback}) async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
return IOSVideoDownloader.instance.download(url: url, callback: callback);
|
||||
return IOSVideoDownloader.instance
|
||||
.download(url: url, callback: callback);
|
||||
}
|
||||
if (!_isInited && !await init()) return "缓存加载失败";
|
||||
if (url.isEmpty) return "视频链接为空";
|
||||
@@ -208,7 +216,9 @@ class VideoDownloadManager {
|
||||
|
||||
/// 删除指定 url 的缓存文件,同时清掉它的回调
|
||||
Future<bool> delete(String url) async {
|
||||
final ret = Platform.isIOS ? await IOSVideoDownloader.instance.delete(url) : await M3u8Downloader.delete(url);
|
||||
final ret = Platform.isIOS
|
||||
? await IOSVideoDownloader.instance.delete(url)
|
||||
: await M3u8Downloader.delete(url);
|
||||
_callbacks.remove(taskKey(url));
|
||||
return ret;
|
||||
}
|
||||
@@ -263,13 +273,18 @@ _onProgress(dynamic args) {
|
||||
_onSuccess(dynamic args) {
|
||||
debugLog("=======load success!!!!!!");
|
||||
debugLog(args);
|
||||
IsolateNameServer.lookupPortByName(_portName)
|
||||
?.send({"status": 1, "url": args["url"], "filePath": args["filePath"], "dir": args["dir"]});
|
||||
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"]});
|
||||
IsolateNameServer.lookupPortByName(_portName)
|
||||
?.send({"status": 0, "url": args["url"]});
|
||||
}
|
||||
|
||||
/// [VideoDownloadManager.searchInfo] 的查询结果:某个 url 当前的下载状态。
|
||||
@@ -280,7 +295,8 @@ class DownloadInfo {
|
||||
String? isLoaderRunning; // "1" 下载中 / "0" 已暂停;已完成时不下发
|
||||
String? status; // "2" 下载完成;下载中时由 isLoaderRunning 推出 "1"
|
||||
|
||||
DownloadInfo({this.progress, this.localPath, this.status, this.isLoaderRunning});
|
||||
DownloadInfo(
|
||||
{this.progress, this.localPath, this.status, this.isLoaderRunning});
|
||||
|
||||
DownloadInfo.fromJson(Map json) {
|
||||
localPath = json['localPath'];
|
||||
|
||||
@@ -5,9 +5,9 @@ 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:hgmj/hj_utils/image_util.dart';
|
||||
import 'package:hgmj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:wydj/hj_utils/image_util.dart';
|
||||
import 'package:wydj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
|
||||
import '../debug_log.dart';
|
||||
import 'video_download_manager.dart';
|
||||
@@ -25,7 +25,8 @@ class VideoSaveUtil {
|
||||
|
||||
/// 视频转换成 mp4 保存到本地相册中
|
||||
/// 在 VideoDownloadManager 下载完成后调用
|
||||
Future<void> convertVideoMp4(String url, {DownloadInfo? loadInfo, bool isShowLoading = true}) async {
|
||||
Future<void> convertVideoMp4(String url,
|
||||
{DownloadInfo? loadInfo, bool isShowLoading = true}) async {
|
||||
// iOS 下载产物本身就是 mp4(ffmpeg 下载时已转码),localPath 直接指向 mp4,
|
||||
// 不能走下面的 m3u8 转码逻辑(会把 mp4 当 m3u8 文本读),直接保存即可
|
||||
if (Platform.isIOS) {
|
||||
@@ -46,7 +47,8 @@ class VideoSaveUtil {
|
||||
}
|
||||
try {
|
||||
//队列里补跑的任务(_consumeQueue)不带 loadInfo,得自己查一次
|
||||
final localFileInfo = loadInfo ?? await VideoDownloadManager.instance.searchInfo(url: url);
|
||||
final localFileInfo =
|
||||
loadInfo ?? await VideoDownloadManager.instance.searchInfo(url: url);
|
||||
if (localFileInfo?.localPath?.isNotEmpty != true) {
|
||||
showToast("视频文件不存在");
|
||||
if (isShowLoading) LoadingAlertWidget.cancel();
|
||||
@@ -67,19 +69,21 @@ class VideoSaveUtil {
|
||||
}
|
||||
|
||||
final m3u8ContentList = await m3u8Path.readAsLines();
|
||||
final keyLineIndex = m3u8ContentList.indexWhere((e) => e.startsWith('#EXT-X-KEY'));
|
||||
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);
|
||||
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"');
|
||||
m3u8ContentList[keyLineIndex] = m3u8ContentList[keyLineIndex]
|
||||
.replaceFirst(RegExp(r'URI=[^,]*'), 'URI="$secKeyPath"');
|
||||
}
|
||||
final localM3u8File = File('$dirPath/download.m3u8');
|
||||
await localM3u8File.writeAsString(m3u8ContentList.join('\n'));
|
||||
@@ -88,7 +92,8 @@ class VideoSaveUtil {
|
||||
// 也不会因为播放器在老机型上初始化失败把整个保存流程带崩
|
||||
final totalDurationMs = _totalDurationMs(m3u8ContentList);
|
||||
|
||||
final command = '-allowed_extensions ALL -i "${localM3u8File.path}" -c copy -bsf:a aac_adtstoasc -movflags +faststart "$outputPath"';
|
||||
final command =
|
||||
'-allowed_extensions ALL -i "${localM3u8File.path}" -c copy -bsf:a aac_adtstoasc -movflags +faststart "$outputPath"';
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
(session) async {
|
||||
@@ -106,7 +111,8 @@ class VideoSaveUtil {
|
||||
await File(outputPath).delete();
|
||||
} catch (_) {}
|
||||
showToast("保存失败");
|
||||
debugLog('转换失败,返回码:${returnCode?.getValue()}', await session.getFailStackTrace());
|
||||
debugLog('转换失败,返回码:${returnCode?.getValue()}',
|
||||
await session.getFailStackTrace());
|
||||
}
|
||||
_consumeQueue();
|
||||
},
|
||||
@@ -136,10 +142,12 @@ class VideoSaveUtil {
|
||||
}
|
||||
|
||||
/// iOS:下载完成的文件已是 mp4,直接保存到相册
|
||||
Future<void> _saveIosMp4(String url, {DownloadInfo? loadInfo, bool isShowLoading = true}) async {
|
||||
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;
|
||||
mp4Path =
|
||||
(await VideoDownloadManager.instance.searchInfo(url: url))?.localPath;
|
||||
}
|
||||
if (mp4Path?.isNotEmpty != true || !await File(mp4Path!).exists()) {
|
||||
showToast("视频文件不存在");
|
||||
@@ -178,7 +186,8 @@ class VideoSaveUtil {
|
||||
/// local.m3u8 的 KEY 行是原生插件重新拼的,两种情况会拼坏:绝对地址会多包一层引号
|
||||
/// (取到空串)、带 query 的地址会被 '=' 截断(取到半截地址),所以优先从同目录的
|
||||
/// remote.m3u8(下载时存下的原始 m3u8 全文)里取原始 URI,相对地址再按视频地址补全
|
||||
Future<String> _resolveKeyUrl(String dirPath, String localKeyLine, String videoUrl) async {
|
||||
Future<String> _resolveKeyUrl(
|
||||
String dirPath, String localKeyLine, String videoUrl) async {
|
||||
String raw = '';
|
||||
final remoteFile = File('$dirPath/remote.m3u8');
|
||||
if (await remoteFile.exists()) {
|
||||
@@ -190,7 +199,8 @@ class VideoSaveUtil {
|
||||
}
|
||||
//remote.m3u8 丢了才退回 local 的 KEY 行,多余的引号一并去掉
|
||||
if (raw.isEmpty) {
|
||||
raw = (RegExp(r'URI=([^,]*)').firstMatch(localKeyLine)?.group(1) ?? '').replaceAll('"', '');
|
||||
raw = (RegExp(r'URI=([^,]*)').firstMatch(localKeyLine)?.group(1) ?? '')
|
||||
.replaceAll('"', '');
|
||||
}
|
||||
if (raw.isEmpty) return '';
|
||||
try {
|
||||
@@ -229,7 +239,8 @@ class VideoSaveUtil {
|
||||
double seconds = 0;
|
||||
for (final line in lines) {
|
||||
if (!line.startsWith('#EXTINF:')) continue;
|
||||
seconds += double.tryParse(line.substring(8).split(',').first.trim()) ?? 0;
|
||||
seconds +=
|
||||
double.tryParse(line.substring(8).split(',').first.trim()) ?? 0;
|
||||
}
|
||||
return (seconds * 1000).round();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user