logo和名称替换
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgmj/config/address.dart';
|
||||
import 'package:hgmj/hj_model/home/module_detail_model.dart';
|
||||
import 'package:hgmj/hj_model/splash/ads_model.dart';
|
||||
import 'package:hgmj/hj_model/user/user_info_model.dart';
|
||||
import 'package:hgmj/hj_model/video_model.dart';
|
||||
import 'package:hgmj/hj_utils/light_model.dart';
|
||||
import 'package:hgmj/hj_utils/store_keys.dart';
|
||||
import 'package:hgmj/hj_utils/text_util.dart';
|
||||
import 'package:wydj/config/address.dart';
|
||||
import 'package:wydj/hj_model/home/module_detail_model.dart';
|
||||
import 'package:wydj/hj_model/splash/ads_model.dart';
|
||||
import 'package:wydj/hj_model/user/user_info_model.dart';
|
||||
import 'package:wydj/hj_model/video_model.dart';
|
||||
import 'package:wydj/hj_utils/light_model.dart';
|
||||
import 'package:wydj/hj_utils/store_keys.dart';
|
||||
import 'package:wydj/hj_utils/text_util.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// 广告总管:广告数据(内存+本地缓存)、免广告策略、列表插广告
|
||||
@@ -29,12 +29,14 @@ class AdManager {
|
||||
}
|
||||
|
||||
/// 按 [position] 同步取广告,走内存缓存
|
||||
List<AdsInfoModel> adsByType(int? position) => _filterByPosition(_adsList, position);
|
||||
List<AdsInfoModel> adsByType(int? position) =>
|
||||
_filterByPosition(_adsList, position);
|
||||
|
||||
/// 按 [position] 异步取上次展示的广告,直接读磁盘(不走/不写内存缓存,splash 启动早期用)
|
||||
Future<List<AdsInfoModel>> lastAdsByType(int? position) async {
|
||||
if (position == null) return const [];
|
||||
return _filterByPosition(_decodeAds(await lightKV.getString(StoreKeys.ADS_LIST)), position);
|
||||
return _filterByPosition(
|
||||
_decodeAds(await lightKV.getString(StoreKeys.ADS_LIST)), position);
|
||||
}
|
||||
|
||||
/// 保存服务端下发的广告:cover 补全成完整地址后刷内存、写磁盘
|
||||
@@ -42,7 +44,9 @@ class AdManager {
|
||||
for (final model in ads ?? const <AdsInfoModel>[]) {
|
||||
final cover = model.cover;
|
||||
// 完整链接只取 path 拼当前图片域名,相对路径直接拼
|
||||
final subPath = cover?.startsWith('http') == true ? Uri.tryParse(cover!)?.path : cover;
|
||||
final subPath = cover?.startsWith('http') == true
|
||||
? Uri.tryParse(cover!)?.path
|
||||
: cover;
|
||||
model.cover = path.join(Address.baseImagePath ?? '', subPath);
|
||||
}
|
||||
_adsList = ads;
|
||||
@@ -58,7 +62,9 @@ class AdManager {
|
||||
List<AdsInfoModel>? _decodeAds(String? jsonStr) {
|
||||
if (TextUtil.isEmpty(jsonStr)) return null;
|
||||
try {
|
||||
return (jsonDecode(jsonStr!) as List?)?.map((o) => AdsInfoModel.fromMap(o)).toList();
|
||||
return (jsonDecode(jsonStr!) as List?)
|
||||
?.map((o) => AdsInfoModel.fromMap(o))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
@@ -91,14 +97,17 @@ class AdManager {
|
||||
// 注册时刻起算 type 分钟:注册时间是固定的绝对时刻,重进 app 既不会续期也不会丢剩余时长
|
||||
// createdAt 是 UTC 串,缺时区标记时补 Z 再解析,否则会被当成本地时间差出时区偏移
|
||||
final raw = userInfo?.createdAt ?? '';
|
||||
final registeredAt = DateTime.tryParse(raw.endsWith('Z') || raw.contains('+') ? raw : '${raw}Z')?.toLocal();
|
||||
final registeredAt = DateTime.tryParse(
|
||||
raw.endsWith('Z') || raw.contains('+') ? raw : '${raw}Z')
|
||||
?.toLocal();
|
||||
_adFreeUntil = registeredAt?.add(Duration(minutes: type));
|
||||
}
|
||||
}
|
||||
|
||||
/// 专题列表插入广告:每隔 [adGap] 条普通内容插一条广告位
|
||||
/// [modelArr] 原始列表(会被原地修改);[advList] 广告数据;[adGap] 间隔条数
|
||||
void insertSectionAds(List<AllSection> modelArr, List<AdsInfoModel> advList, {int adGap = 5}) {
|
||||
void insertSectionAds(List<AllSection> modelArr, List<AdsInfoModel> advList,
|
||||
{int adGap = 5}) {
|
||||
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
|
||||
int countGap = 0; // 距上一条广告已累计的普通内容数
|
||||
modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告,避免重复插入
|
||||
@@ -127,7 +136,8 @@ class AdManager {
|
||||
}
|
||||
|
||||
/// 视频流插入「单条随机广告」:每隔 [adGap] 条插一条,广告按 adIndex 轮播取用
|
||||
void insertRandomAds(List<VideoModel> modelArr, List<AdsInfoModel> advList, {int adGap = 5}) {
|
||||
void insertRandomAds(List<VideoModel> modelArr, List<AdsInfoModel> advList,
|
||||
{int adGap = 5}) {
|
||||
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
|
||||
int countGap = 0; // 距上一条广告已累计的普通内容数
|
||||
int adIndex = 0; // 当前取到第几条广告,与 advList 取模实现轮播
|
||||
@@ -158,7 +168,8 @@ class AdManager {
|
||||
}
|
||||
|
||||
/// 视频流插入「整组广告」:每隔 [adGap] 条插一条,广告位携带整个 advList(与 [insertRandomAds] 的单条随机不同)
|
||||
void insertGroupAds(List<VideoModel> modelArr, List<AdsInfoModel> advList, {int adGap = 5}) {
|
||||
void insertGroupAds(List<VideoModel> modelArr, List<AdsInfoModel> advList,
|
||||
{int adGap = 5}) {
|
||||
if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return;
|
||||
int countGap = 0; // 距上一条广告已累计的普通内容数
|
||||
modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/widget/card_swiper/src/swiper.dart';
|
||||
import 'package:hgmj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:hgmj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/widget/card_swiper/src/swiper.dart';
|
||||
import 'package:wydj/tools_base/widget/image_browser_page.dart';
|
||||
import 'package:wydj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
import 'ads_item.dart';
|
||||
@@ -73,7 +73,13 @@ class _AdsBannerState extends State<AdsBanner> {
|
||||
SizedBox(height: 256, child: _swiper()),
|
||||
12.sizeBoxH,
|
||||
if (_count > 1)
|
||||
Center(child: _indicator(space: 4, dotSize: 4, selectWidth: 8, color: widget.color, selectColor: widget.selectColor)),
|
||||
Center(
|
||||
child: _indicator(
|
||||
space: 4,
|
||||
dotSize: 4,
|
||||
selectWidth: 8,
|
||||
color: widget.color,
|
||||
selectColor: widget.selectColor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -98,15 +104,22 @@ class _AdsBannerState extends State<AdsBanner> {
|
||||
dotSize: 2,
|
||||
selectWidth: 5,
|
||||
//视频广告 cell:主题黄(未选中半透明,选中实心)
|
||||
color: widget.color ?? const Color(0xffFFD900).withValues(alpha: 0.4),
|
||||
selectColor: widget.selectColor ?? const Color(0xffFFD900),
|
||||
color: widget.color ??
|
||||
const Color(0xffFFD900).withValues(alpha: 0.4),
|
||||
selectColor:
|
||||
widget.selectColor ?? const Color(0xffFFD900),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Positioned(
|
||||
bottom: 7,
|
||||
right: 7,
|
||||
child: _indicator(space: 4, dotSize: 4, selectWidth: 8, color: widget.color, selectColor: widget.selectColor),
|
||||
child: _indicator(
|
||||
space: 4,
|
||||
dotSize: 4,
|
||||
selectWidth: 8,
|
||||
color: widget.color,
|
||||
selectColor: widget.selectColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -189,8 +202,12 @@ class DotIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBar = style == IndicatorStyle.bar;
|
||||
final activeColor = selectColor ?? (isBar ? const Color(0xffE1351F) : Colors.white);
|
||||
final inactiveColor = color ?? (isBar ? const Color(0xffE1351F).withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.5));
|
||||
final activeColor =
|
||||
selectColor ?? (isBar ? const Color(0xffE1351F) : Colors.white);
|
||||
final inactiveColor = color ??
|
||||
(isBar
|
||||
? const Color(0xffE1351F).withValues(alpha: 0.3)
|
||||
: Colors.white.withValues(alpha: 0.5));
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -248,7 +265,8 @@ class _ImageSwiperState extends State<ImageSwiper> {
|
||||
loop: _count > 1,
|
||||
itemBuilder: (_, index) => InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => ImageBrowserPage.open(widget.imgs ?? [], index: index),
|
||||
onTap: () =>
|
||||
ImageBrowserPage.open(widget.imgs ?? [], index: index),
|
||||
child: NetworkImageLoader(imageUrl: widget.imgs![index]),
|
||||
),
|
||||
onIndexChanged: (index) => _index.value = index,
|
||||
@@ -260,7 +278,8 @@ class _ImageSwiperState extends State<ImageSwiper> {
|
||||
right: 12,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _index,
|
||||
builder: (_, index, __) => DotIndicator(count: _count, index: index, space: 6),
|
||||
builder: (_, index, __) =>
|
||||
DotIndicator(count: _count, index: index, space: 6),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -303,8 +322,12 @@ class _AiBannerState extends State<AiBanner> {
|
||||
final item = widget.models?[index];
|
||||
//左:原图 右:AI 生成图
|
||||
return Row(children: [
|
||||
Expanded(child: NetworkImageLoader(imageUrl: item?.cover ?? '', borderRadius: 0)),
|
||||
Expanded(child: NetworkImageLoader(imageUrl: item?.newUrl ?? '', borderRadius: 0)),
|
||||
Expanded(
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: item?.cover ?? '', borderRadius: 0)),
|
||||
Expanded(
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: item?.newUrl ?? '', borderRadius: 0)),
|
||||
]);
|
||||
},
|
||||
onIndexChanged: (index) => _index.value = index,
|
||||
@@ -316,7 +339,8 @@ class _AiBannerState extends State<AiBanner> {
|
||||
right: 10,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _index,
|
||||
builder: (_, index, __) => DotIndicator(count: _count, index: index),
|
||||
builder: (_, index, __) =>
|
||||
DotIndicator(count: _count, index: index),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_infinite_marquee/flutter_infinite_marquee.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/ad_manager.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/ad_manager.dart';
|
||||
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
import 'ads_banner.dart';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
@@ -70,7 +70,8 @@ class _AdsItemState extends State<AdsItem> {
|
||||
width: Get.width,
|
||||
height: Get.height,
|
||||
fit: BoxFit.cover,
|
||||
placeHolderWidget: Image.asset('ic_splash_bg.webp'.launchPath, fit: BoxFit.cover, width: Get.width, height: Get.height),
|
||||
placeHolderWidget: Image.asset('ic_splash_bg.webp'.launchPath,
|
||||
fit: BoxFit.cover, width: Get.width, height: Get.height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +81,8 @@ class _AdsItemState extends State<AdsItem> {
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: widget.aspectRatio ?? 1,
|
||||
child: NetworkImageLoader(imageUrl: adInfo.cover ?? '', borderRadius: 12),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: adInfo.cover ?? '', borderRadius: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -111,7 +113,10 @@ class _AdsItemState extends State<AdsItem> {
|
||||
10.sizeBoxH,
|
||||
Text(
|
||||
adInfo.title ?? '',
|
||||
style: const TextStyle(color: Color(0xffdddfdf), fontWeight: FontWeight.w300, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Color(0xffdddfdf),
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -125,7 +130,9 @@ class _AdsItemState extends State<AdsItem> {
|
||||
fit: BoxFit.fill,
|
||||
);
|
||||
final ratio = widget.aspectRatio;
|
||||
return ratio == null ? image : AspectRatio(aspectRatio: ratio, child: image);
|
||||
return ratio == null
|
||||
? image
|
||||
: AspectRatio(aspectRatio: ratio, child: image);
|
||||
}
|
||||
|
||||
//水平样式:左图 + 标题,右「立即下载」按钮
|
||||
@@ -136,19 +143,30 @@ class _AdsItemState extends State<AdsItem> {
|
||||
Row(
|
||||
children: [
|
||||
//图片自己切 10 的圆角
|
||||
NetworkImageLoader(imageUrl: adInfo.cover ?? '', borderRadius: 10, width: 50, height: 50),
|
||||
NetworkImageLoader(
|
||||
imageUrl: adInfo.cover ?? '',
|
||||
borderRadius: 10,
|
||||
width: 50,
|
||||
height: 50),
|
||||
10.sizeBoxW,
|
||||
Text(
|
||||
adInfo.title ?? '',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w300, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 72,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: const Color(0xff3476FF), borderRadius: BorderRadius.circular(14)),
|
||||
child: const Center(child: Text('立即下载', style: TextStyle(color: Colors.white, fontSize: 12))),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xff3476FF),
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
child: const Center(
|
||||
child: Text('立即下载',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Vendored
+18
-10
@@ -3,11 +3,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart' show DefaultCacheManager;
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
|
||||
show DefaultCacheManager;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:hgmj/tools_base/image/image_disk_cache.dart';
|
||||
import 'package:hgmj/tools_base/video_download/video_download_manager.dart';
|
||||
import 'package:wydj/tools_base/image/image_disk_cache.dart';
|
||||
import 'package:wydj/tools_base/video_download/video_download_manager.dart';
|
||||
|
||||
import '../../hj_utils/file_util.dart';
|
||||
import '../../hj_utils/video_cache_manager.dart';
|
||||
@@ -16,11 +17,13 @@ import 'image_cache_manager.dart';
|
||||
///加载缓存 统计缓存大小
|
||||
Future<String> loadCache() async {
|
||||
double total = 0;
|
||||
Directory videoCacheDir = Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
Directory videoCacheDir =
|
||||
Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
if (await videoCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
|
||||
}
|
||||
Directory videoLoadedDir = Directory(await VideoDownloadManager.instance.cacheDir());
|
||||
Directory videoLoadedDir =
|
||||
Directory(await VideoDownloadManager.instance.cacheDir());
|
||||
if (await videoLoadedDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoLoadedDir);
|
||||
}
|
||||
@@ -61,7 +64,8 @@ Future<double> _getTotalSizeOfFilesInDir(final FileSystemEntity file) async {
|
||||
double total = 0;
|
||||
// recursive 已一次性列出所有后代文件,只累加 File 即可;不再对子目录二次递归,
|
||||
// 避免同一文件被重复计入导致缓存大小翻倍。异步 list 替代 listSync,避免大目录阻塞 UI。
|
||||
await for (final child in file.list(recursive: true, followLinks: false)) {
|
||||
await for (final child
|
||||
in file.list(recursive: true, followLinks: false)) {
|
||||
if (child is File) {
|
||||
total += (await child.length()).toDouble();
|
||||
}
|
||||
@@ -95,10 +99,12 @@ Future clearCacheIfNeed({bool force = false, bool isHandle = false}) async {
|
||||
//await VideoDownloadManager.instance.emptyCache();
|
||||
// await ImageDiskCache.emptyCache();
|
||||
}
|
||||
debugPrint(" ------------------ VideoCacheManager ImageCacheManager clear completely");
|
||||
debugPrint(
|
||||
" ------------------ VideoCacheManager ImageCacheManager clear completely");
|
||||
} else {
|
||||
double total = 0;
|
||||
Directory videoCacheDir = Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
Directory videoCacheDir =
|
||||
Directory(await VideoCacheManager().getFilePath() ?? '');
|
||||
if (await videoCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(videoCacheDir);
|
||||
}
|
||||
@@ -107,7 +113,8 @@ Future clearCacheIfNeed({bool force = false, bool isHandle = false}) async {
|
||||
await VideoCacheManager().emptyCache();
|
||||
}
|
||||
total = 0;
|
||||
Directory imageCacheDir = Directory(await ImageCacheManager().getFilePath());
|
||||
Directory imageCacheDir =
|
||||
Directory(await ImageCacheManager().getFilePath());
|
||||
if (await imageCacheDir.exists()) {
|
||||
total += await _getTotalSizeOfFilesInDir(imageCacheDir);
|
||||
}
|
||||
@@ -124,7 +131,8 @@ clearAllCache(bool isHandle) async {
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
|
||||
debugPrint(" ----------- PaintingBinding.instance.imageCache.clearLiveImages()");
|
||||
debugPrint(
|
||||
" ----------- PaintingBinding.instance.imageCache.clearLiveImages()");
|
||||
}
|
||||
|
||||
///递归方式删除目录
|
||||
|
||||
+9
-5
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:http/http.dart' hide Response;
|
||||
|
||||
import '../../extension/extensions.dart';
|
||||
@@ -16,7 +16,8 @@ class DioFileService extends FileService {
|
||||
final _dio = createDio();
|
||||
|
||||
@override
|
||||
Future<FileServiceResponse> get(String url, {Map<String, String>? headers = const {}}) async {
|
||||
Future<FileServiceResponse> get(String url,
|
||||
{Map<String, String>? headers = const {}}) async {
|
||||
// cancelToken 用文件名注册、下载结束后按同一 key 移除
|
||||
//(原来 createToken 用 name 而 remove 用 url,key 不一致:既移不掉导致泄漏,又会 firstWhere 抛 StateError)
|
||||
final name = FileUtil.getName(url);
|
||||
@@ -35,7 +36,8 @@ class DioFileService extends FileService {
|
||||
Response<ResponseBody>? resp;
|
||||
try {
|
||||
debugLog("DioFileService get: $url");
|
||||
resp = await _dio.get<ResponseBody>(url, cancelToken: token, options: options);
|
||||
resp = await _dio.get<ResponseBody>(url,
|
||||
cancelToken: token, options: options);
|
||||
} catch (e) {
|
||||
debugLog("DioFileService get error: $url -> $e");
|
||||
} finally {
|
||||
@@ -53,9 +55,11 @@ class DioFileService extends FileService {
|
||||
if (values.notEmpty()) respHeaders[key] = values.first;
|
||||
});
|
||||
// Content-Length 可能缺失/非数字,安全解析(原 contentLengthS[0] 在空串时会 RangeError);缺失传 null 表示长度未知
|
||||
final contentLength = int.tryParse(respHeaders[Headers.contentLengthHeader] ?? '');
|
||||
final contentLength =
|
||||
int.tryParse(respHeaders[Headers.contentLengthHeader] ?? '');
|
||||
|
||||
debugLog("DioFileService resp($statusCode): $url contentLength:$contentLength");
|
||||
debugLog(
|
||||
"DioFileService resp($statusCode): $url contentLength:$contentLength");
|
||||
|
||||
return HttpGetResponse(StreamedResponse(
|
||||
resp.data!.stream,
|
||||
|
||||
+12
-7
@@ -1,9 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgmj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgmj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgmj/tools_base/cache/history/base_history_record_store.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/hj_model/drama/drama_models.dart';
|
||||
import 'package:wydj/hj_model/drama_media_info.dart';
|
||||
import 'package:wydj/tools_base/cache/history/base_history_record_store.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
|
||||
/// 短剧观看记录:纯本地,不依赖后端。一部剧一行(看到哪一集、第几秒 + 剧信息快照),
|
||||
/// 最多留 [BaseHistoryRecordStore.maxRows] 条,超了先淘汰最久没看的那部。
|
||||
@@ -114,7 +114,8 @@ class DramaResumeStore extends BaseHistoryRecordStore {
|
||||
record(
|
||||
mediaId: mediaId,
|
||||
//空 contentId 会被 record 挡掉,那样这次刷新就白记了,退回当前这一集
|
||||
contentId: last?.contentId.isNotEmpty == true ? last!.contentId : contentId,
|
||||
contentId:
|
||||
last?.contentId.isNotEmpty == true ? last!.contentId : contentId,
|
||||
seconds: last?.progressSeconds ?? 0,
|
||||
drama: drama ?? last?.drama,
|
||||
);
|
||||
@@ -130,7 +131,10 @@ class DramaResumeStore extends BaseHistoryRecordStore {
|
||||
}) {
|
||||
//空串和 null 一样要挡:contentId 为空的记录谁都匹配不上,
|
||||
//却会把这部剧上一条好记录顶掉,还会让 _resumeIndex 白翻完整部剧的分页
|
||||
if (mediaId == null || mediaId.isEmpty || contentId == null || contentId.isEmpty) return;
|
||||
if (mediaId == null ||
|
||||
mediaId.isEmpty ||
|
||||
contentId == null ||
|
||||
contentId.isEmpty) return;
|
||||
final item = DramaResume(
|
||||
mediaId: mediaId,
|
||||
contentId: contentId,
|
||||
@@ -141,7 +145,8 @@ class DramaResumeStore extends BaseHistoryRecordStore {
|
||||
_put(mediaId, item);
|
||||
//不等落盘:主键冲突走 REPLACE,顺带把 create_time 刷成本次时间,超量时最久没看的先淘汰。
|
||||
//必须接住异常,否则 db 出错会变成没人管的 async error
|
||||
save(modelId: mediaId, modelDataJson: jsonEncode(item.toJson())).catchError((e) {
|
||||
save(modelId: mediaId, modelDataJson: jsonEncode(item.toJson()))
|
||||
.catchError((e) {
|
||||
debugLog('短剧续播记录写入失败', e.toString());
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_utils/file_util.dart';
|
||||
@@ -34,9 +34,11 @@ class FileUploadTool {
|
||||
// ==================== 图片上传 ====================
|
||||
|
||||
/// 上传单张图片(读本地文件 → 压缩 → 上传),本地文件读不到时返回 null
|
||||
Future<ImageUploadResultModel?> uploadImage(String path, {Function(int, int)? callback}) async {
|
||||
Future<ImageUploadResultModel?> uploadImage(String path,
|
||||
{Function(int, int)? callback}) async {
|
||||
final ext = FileUtil.getNameSuffix(path);
|
||||
final fileName = '${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.$ext';
|
||||
final fileName =
|
||||
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.$ext';
|
||||
Uint8List fileData;
|
||||
try {
|
||||
// 相册图被删/路径失效时 readAsBytes 会抛,统一转成 null 失败,别让异常穿到调用方
|
||||
@@ -55,8 +57,10 @@ class FileUploadTool {
|
||||
String? fileName,
|
||||
Function(int, int)? callback,
|
||||
}) async {
|
||||
final compressedData = await _compressImage(imageData, maxSize: maxImageSize);
|
||||
final name = fileName ?? '${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.jpg';
|
||||
final compressedData =
|
||||
await _compressImage(imageData, maxSize: maxImageSize);
|
||||
final name = fileName ??
|
||||
'${DateTime.now().toIso8601String()}_${Random().nextInt(1024)}.jpg';
|
||||
debugLog("开始上传----", name);
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
@@ -142,10 +146,12 @@ class FileUploadTool {
|
||||
LoadingAlertWidget.show(title: "$title...");
|
||||
try {
|
||||
results = await uploadImageList(paths, onProgress: (progress) {
|
||||
LoadingAlertWidget.showExchangeTitle("$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
LoadingAlertWidget.showExchangeTitle(
|
||||
"$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
});
|
||||
} catch (e) {
|
||||
debugLog("uploadImagesWithProgress()...error:$e"); // 兜底:异常也要把 loading 关掉,别卡死转圈
|
||||
debugLog(
|
||||
"uploadImagesWithProgress()...error:$e"); // 兜底:异常也要把 loading 关掉,别卡死转圈
|
||||
} finally {
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
@@ -207,7 +213,9 @@ class FileUploadTool {
|
||||
final start = index * patchSize;
|
||||
final end = min(start + patchSize, fileBytes.length);
|
||||
// 正常 start 必 < fileBytes.length;越界(文件被截短)时取空块,不抛 RangeError
|
||||
final blockData = start < end ? Uint8List.sublistView(fileBytes, start, end) : Uint8List(0);
|
||||
final blockData = start < end
|
||||
? Uint8List.sublistView(fileBytes, start, end)
|
||||
: Uint8List(0);
|
||||
final postData = {
|
||||
'data': base64.encode(blockData),
|
||||
'pos': index + 1,
|
||||
@@ -220,7 +228,8 @@ class FileUploadTool {
|
||||
for (int attempt = 0; attempt <= maxChunkRetry; attempt++) {
|
||||
if (failed) return; // 其它 worker 已判定失败,无谓再传
|
||||
if (attempt > 0) {
|
||||
await Future.delayed(Duration(milliseconds: 500 * (1 << (attempt - 1))));
|
||||
await Future.delayed(
|
||||
Duration(milliseconds: 500 * (1 << (attempt - 1))));
|
||||
debugLog("uploadVideo() 第${index + 1}片 第$attempt 次重试");
|
||||
}
|
||||
try {
|
||||
@@ -280,7 +289,8 @@ class FileUploadTool {
|
||||
}) async {
|
||||
LoadingAlertWidget.show(title: "$title...");
|
||||
final result = await uploadVideo(localPath, onProgress: (progress) {
|
||||
LoadingAlertWidget.showExchangeTitle("$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
LoadingAlertWidget.showExchangeTitle(
|
||||
"$title${(progress * 100).toStringAsFixed(1)}%");
|
||||
});
|
||||
LoadingAlertWidget.cancel();
|
||||
|
||||
@@ -314,8 +324,10 @@ class FileUploadTool {
|
||||
|
||||
/// 压缩图片:长边限到 maxImageEdge 保清晰,从高质量起逐档降(每次都从原图重压,避免二次劣化),
|
||||
/// 直到体积 ≤ maxSize 或触及质量下限 minCompressQuality(保清晰优先,不无脑压糊)。统一转 JPEG。
|
||||
Future<Uint8List> _compressImage(Uint8List bytes, {required int maxSize}) async {
|
||||
Future<Uint8List> compress(int quality) => FlutterImageCompress.compressWithList(
|
||||
Future<Uint8List> _compressImage(Uint8List bytes,
|
||||
{required int maxSize}) async {
|
||||
Future<Uint8List> compress(int quality) =>
|
||||
FlutterImageCompress.compressWithList(
|
||||
bytes,
|
||||
minWidth: maxImageEdge,
|
||||
minHeight: maxImageEdge,
|
||||
@@ -329,7 +341,8 @@ class FileUploadTool {
|
||||
quality -= 15; // 90 → 75 → 60
|
||||
result = await compress(quality);
|
||||
}
|
||||
debugLog("图片压缩 ${bytes.length ~/ 1024}KB → ${result.length ~/ 1024}KB (q$quality)");
|
||||
debugLog(
|
||||
"图片压缩 ${bytes.length ~/ 1024}KB → ${result.length ~/ 1024}KB (q$quality)");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hgmj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:hgmj/hj_utils/date_time_util.dart';
|
||||
import 'package:hgmj/hj_utils/light_model.dart';
|
||||
import 'package:hgmj/tools_base/ad_manager.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:wydj/hj_utils/api_service/mine_service.dart';
|
||||
import 'package:wydj/hj_utils/date_time_util.dart';
|
||||
import 'package:wydj/hj_utils/light_model.dart';
|
||||
import 'package:wydj/tools_base/ad_manager.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_model/user/user_info_model.dart';
|
||||
@@ -55,7 +55,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
|
||||
/// 设置锁屏密码
|
||||
Future<bool?> setLockPassword(List<int> input) async {
|
||||
final res = await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode(input));
|
||||
final res =
|
||||
await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode(input));
|
||||
if (res == true) {
|
||||
showToast('设置密码成功');
|
||||
//落盘写的是 input(覆盖),内存也得覆盖;addAll 会把旧密码留在前面,与落盘的对不上
|
||||
@@ -74,7 +75,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
showToast('密码验证失败,请重新输入~');
|
||||
return false;
|
||||
}
|
||||
final res = await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode([]));
|
||||
final res =
|
||||
await lightKV.setString(StoreKeys.PASSWORD_LOCK, json.encode([]));
|
||||
if (res == true) {
|
||||
showToast('取消锁屏密码成功');
|
||||
password.clear();
|
||||
@@ -145,7 +147,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
|
||||
/// 二维码登录
|
||||
/// [refresh] 是否刷新关联 globalStore 状态的页面,==false 则仅发起网络请求
|
||||
Future<UserInfoModel?> loginByQr(String qr, {String paste = "", bool refresh = true}) async {
|
||||
Future<UserInfoModel?> loginByQr(String qr,
|
||||
{String paste = "", bool refresh = true}) async {
|
||||
//1.存下当前 token,扫码失败要还原
|
||||
final token = await netManager.getToken();
|
||||
//2.设置 token 为空,否则获取的二维码也是老账号
|
||||
@@ -166,7 +169,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
|
||||
/// 设备登录
|
||||
/// [refresh] 刷新所有 user 全局状态
|
||||
Future<UserInfoModel?> loginByDevice(String deviceId, {String paste = "", bool refresh = true}) async {
|
||||
Future<UserInfoModel?> loginByDevice(String deviceId,
|
||||
{String paste = "", bool refresh = true}) async {
|
||||
final userInfo = await MineService.devLogin(
|
||||
deviceId,
|
||||
"",
|
||||
@@ -182,7 +186,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
|
||||
/// 手机号登录
|
||||
/// [refresh] 刷新所有 user 全局状态
|
||||
Future<UserInfoModel?> loginByMobile(String mobile, String code, {String paste = "", bool refresh = true}) async {
|
||||
Future<UserInfoModel?> loginByMobile(String mobile, String code,
|
||||
{String paste = "", bool refresh = true}) async {
|
||||
final userInfo = await MineService.mobileLogin(
|
||||
mobile,
|
||||
code,
|
||||
@@ -214,7 +219,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 处理登录结果:写 token、按需刷新;失败时还原原 token
|
||||
Future<UserInfoModel?> _onLogin(UserInfoModel? userInfo, String? oldToken, bool refresh) async {
|
||||
Future<UserInfoModel?> _onLogin(
|
||||
UserInfoModel? userInfo, String? oldToken, bool refresh) async {
|
||||
if (userInfo != null) {
|
||||
if (TextUtil.isNotEmpty(userInfo.token)) {
|
||||
await netManager.setToken(userInfo.token);
|
||||
@@ -233,7 +239,8 @@ class GlobalStore extends ChangeNotifier {
|
||||
|
||||
/// 更新并获取用户信息;[map] 为空则仅拉取用户信息
|
||||
/// [refresh] 刷新所有 user 全局状态(会触发 Consumer 包裹的 widget 重建)
|
||||
Future<UserInfoModel?> updateUserInfo({Map<String, dynamic>? map, bool refresh = true}) async {
|
||||
Future<UserInfoModel?> updateUserInfo(
|
||||
{Map<String, dynamic>? map, bool refresh = true}) async {
|
||||
// 传了 map 才是更新,更新成功后再拉取最新信息
|
||||
final isUpdate = (map?.keys.length ?? 0) > 0;
|
||||
if (isUpdate) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:hgmj/hj_page/short_video/view/video_player_base_logic.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgmj/tools_base/event_bus/events.dart';
|
||||
import 'package:wydj/hj_page/short_video/view/video_player_base_logic.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:wydj/tools_base/event_bus/events.dart';
|
||||
|
||||
/// 监听音频环境变化,自动暂停所有视频播放,防止音频通过手机外放泄露隐私:
|
||||
/// 1. 耳机 / 蓝牙断开(音频切回外放)
|
||||
@@ -40,7 +40,8 @@ class HeadphoneMonitor {
|
||||
});
|
||||
// 兜底:部分机型(华为/荣耀等)蓝牙断开不发 becomingNoisy 广播,改用更底层的设备移除回调兜底
|
||||
_deviceSub = session.devicesChangedEventStream.listen((event) {
|
||||
final unplugged = event.devicesRemoved.any((d) => d.isOutput && _privateOutputTypes.contains(d.type));
|
||||
final unplugged = event.devicesRemoved
|
||||
.any((d) => d.isOutput && _privateOutputTypes.contains(d.type));
|
||||
if (unplugged) {
|
||||
debugLog('音频输出设备移除(蓝牙/耳机) → 自动暂停所有播放');
|
||||
pauseAll();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgmj/assets_tool/app_colors.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:wydj/assets_tool/app_colors.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:loading_animation_widget/loading_animation_widget.dart';
|
||||
|
||||
int logicMultiCount = 0;
|
||||
@@ -12,11 +12,13 @@ class LoadingWidget extends StatelessWidget {
|
||||
final double? height;
|
||||
final double size;
|
||||
|
||||
const LoadingWidget({super.key, this.width = 40, this.height = 20, this.size = 40});
|
||||
const LoadingWidget(
|
||||
{super.key, this.width = 40, this.height = 20, this.size = 40});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LoadingAnimationWidget.horizontalRotatingDots(color: Colors.white, size: size);
|
||||
return LoadingAnimationWidget.horizontalRotatingDots(
|
||||
color: Colors.white, size: size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +55,8 @@ class CErrorWidget extends StatefulWidget {
|
||||
final String? errorMsg;
|
||||
final String? errorMsg2;
|
||||
final VoidCallback? retryOnTap;
|
||||
const CErrorWidget({super.key, this.errorMsg = "什么也没有...", this.errorMsg2, this.retryOnTap});
|
||||
const CErrorWidget(
|
||||
{super.key, this.errorMsg = "什么也没有...", this.errorMsg2, this.retryOnTap});
|
||||
|
||||
@override
|
||||
State<CErrorWidget> createState() => _CErrorWidgetState();
|
||||
@@ -90,7 +93,8 @@ class _CErrorWidgetState extends State<CErrorWidget> {
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
widget.errorMsg ?? '',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.6), fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.6), fontSize: 12),
|
||||
),
|
||||
if (widget.retryOnTap != null) ...[
|
||||
4.sizeBoxH,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgmj/assets_tool/app_colors.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:wydj/assets_tool/app_colors.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/loading/loading_center_widget.dart';
|
||||
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
|
||||
@@ -20,7 +20,8 @@ class LoadingHelper {
|
||||
4.sizeBoxH,
|
||||
Text(
|
||||
msg,
|
||||
style: textStyle(16, AppColors.mainTextColor33, FontWeight.w500),
|
||||
style:
|
||||
textStyle(16, AppColors.mainTextColor33, FontWeight.w500),
|
||||
),
|
||||
]
|
||||
],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgmj/hj_model/home/plate_model.dart';
|
||||
import 'package:hgmj/hj_utils/light_model.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/hj_model/home/plate_model.dart';
|
||||
import 'package:wydj/hj_utils/light_model.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
|
||||
/// 模块亚 tab 排序:合并本地自定义顺序与后端最新数据(后端为内容源,本地只定顺序)
|
||||
class ModuleSortManager {
|
||||
@@ -31,7 +31,7 @@ class ModuleSortManager {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
if(localTabs.isEmpty) return serverTabs;
|
||||
if (localTabs.isEmpty) return serverTabs;
|
||||
// 2.后端装载成 {id: 最新 model}
|
||||
final serverMap = <String, ModuleData>{};
|
||||
for (final t in serverTabs) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:hgmj/config/config.dart';
|
||||
import 'package:hgmj/hj_utils/text_util.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgmj/tools_base/net/http_response_interceptor.dart';
|
||||
import 'package:hgmj/tools_base/net/net_code.dart';
|
||||
import 'package:hgmj/tools_base/net/net_manager.dart';
|
||||
import 'package:wydj/config/config.dart';
|
||||
import 'package:wydj/hj_utils/text_util.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:wydj/tools_base/net/http_response_interceptor.dart';
|
||||
import 'package:wydj/tools_base/net/net_code.dart';
|
||||
import 'package:wydj/tools_base/net/net_manager.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../track_event_manager/device_service.dart';
|
||||
@@ -56,7 +56,8 @@ abstract class HttpManger {
|
||||
if (TextUtil.isNotEmpty(serverTimeS)) {
|
||||
_serverTime = DateTime.parse(serverTimeS!);
|
||||
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
|
||||
debugLog("============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
debugLog(
|
||||
"============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,37 +96,50 @@ abstract class HttpManger {
|
||||
}) async {
|
||||
// 给默认值兜底:若 response 非 null 但 data 不是 Map/BaseRespBean(null/String/List),
|
||||
// 下面 resultData 不会被赋值,late 变量访问会抛 LateInitializationError
|
||||
BaseRespBean resultData = BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
BaseRespBean resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
Response? response;
|
||||
try {
|
||||
response = await dio.request(url, queryParameters: queryParameters, options: options, data: data, cancelToken: cancelToken);
|
||||
response = await dio.request(url,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
data: data,
|
||||
cancelToken: cancelToken);
|
||||
} on DioException catch (e) {
|
||||
debugLog('DioException $e');
|
||||
if (e.type == DioExceptionType.cancel) {
|
||||
resultData = BaseRespBean(Code.LOCAL_CANCEL_REQUEST, msg: '请求已经取消~,请重试', data: null);
|
||||
resultData = BaseRespBean(Code.LOCAL_CANCEL_REQUEST,
|
||||
msg: '请求已经取消~,请重试', data: null);
|
||||
} else if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout) {
|
||||
resultData = BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
}
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult == ConnectivityResult.none) {
|
||||
//没有网络
|
||||
resultData = BaseRespBean(Code.LOCAL_NO_NETWORK, msg: '暂无网络,请检查网络设置', data: null);
|
||||
resultData = BaseRespBean(Code.LOCAL_NO_NETWORK,
|
||||
msg: '暂无网络,请检查网络设置', data: null);
|
||||
} else {
|
||||
resultData = BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常,请重新试试~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常,请重新试试~', data: null);
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
resultData = BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
debugLog('SocketException $e');
|
||||
} on HttpException catch (e) {
|
||||
resultData = BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
debugLog('HttpException $e');
|
||||
} on FormatException catch (e) {
|
||||
debugLog('FormatException $e');
|
||||
resultData = BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
} catch (e) {
|
||||
resultData = BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
debugLog(e);
|
||||
}
|
||||
|
||||
@@ -141,7 +155,8 @@ abstract class HttpManger {
|
||||
final data_ = resultData.data;
|
||||
if (data_ is Map) {
|
||||
try {
|
||||
resultData.data = jsonTransformation.call(Map<String, dynamic>.from(data_));
|
||||
resultData.data =
|
||||
jsonTransformation.call(Map<String, dynamic>.from(data_));
|
||||
} catch (e) {
|
||||
print('jsonTransformation error $e');
|
||||
resultData.data = null;
|
||||
@@ -178,14 +193,16 @@ abstract class HttpManger {
|
||||
var adapter = DefaultHttpClientAdapter();
|
||||
|
||||
adapter.onHttpClientCreate = (client) {
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
dio.httpClientAdapter = adapter;
|
||||
}
|
||||
|
||||
// 获取统一的请求头
|
||||
Future<Options> generateRequestOption(String apiUrl, {Options? options, required HttpMethod method}) async {
|
||||
Future<Options> generateRequestOption(String apiUrl,
|
||||
{Options? options, required HttpMethod method}) async {
|
||||
//调用方可以自带 options(比如 X-Request-ID 这种单接口的头),公共头往上加,method 一律以本次请求为准
|
||||
options ??= Options();
|
||||
options.method = method.method;
|
||||
@@ -203,7 +220,11 @@ abstract class HttpManger {
|
||||
options.headers?["api_version"] = "1.0.0";
|
||||
options.headers?["device"] = Platform.operatingSystem;
|
||||
Uri? baseUri = Uri.tryParse(baseUrl);
|
||||
Uri targetUri = Uri(scheme: baseUri?.scheme, host: baseUri?.host, port: baseUri?.port, path: baseUri!.path + apiUrl);
|
||||
Uri targetUri = Uri(
|
||||
scheme: baseUri?.scheme,
|
||||
host: baseUri?.host,
|
||||
port: baseUri?.port,
|
||||
path: baseUri!.path + apiUrl);
|
||||
options.headers?["x-api-key"] = await _sign(targetUri.path);
|
||||
options.headers?["sid"] = TrackSessionManager().currentSid;
|
||||
options.headers?["DeviceModel"] = DeviceInfoService.model;
|
||||
@@ -226,15 +247,20 @@ abstract class HttpManger {
|
||||
var digest = sha1Encrypt.convert(bytes);
|
||||
return 'timestamp=$timestamp;sign=${digest.toString()};nonce=${signObj['nonce']}';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _HttpManagerImp extends HttpManger {
|
||||
@override
|
||||
Future<BaseRespBean> fetchResponseByDELETE(String url,
|
||||
{Map<String, dynamic>? param, Options? options, Function(Map<String, dynamic> json)? jsonTransformation}) async {
|
||||
final reqOptions = await generateRequestOption(url, options: options, method: HttpMethod.delete);
|
||||
return await _requestByUrl(url, options: reqOptions, data: param, jsonTransformation: jsonTransformation);
|
||||
{Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation}) async {
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.delete);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
data: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -244,8 +270,12 @@ class _HttpManagerImp extends HttpManger {
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
final reqOptions = await generateRequestOption(url, options: options, method: HttpMethod.get);
|
||||
return await _requestByUrl(url, options: reqOptions, queryParameters: param, jsonTransformation: jsonTransformation);
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.get);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
queryParameters: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -255,18 +285,24 @@ class _HttpManagerImp extends HttpManger {
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
final reqOptions = await generateRequestOption(url, options: options, method: HttpMethod.post);
|
||||
return await _requestByUrl(url, options: reqOptions, data: param, jsonTransformation: jsonTransformation);
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.post);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
data: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
Future<BaseRespBean> fetchDetectLineResponse(String url, {Options? options, CancelToken? cancelToken}) async {
|
||||
Future<BaseRespBean> fetchDetectLineResponse(String url,
|
||||
{Options? options, CancelToken? cancelToken}) async {
|
||||
options ??= Options(
|
||||
method: HttpMethod.get.method,
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {},
|
||||
);
|
||||
final result = await _requestByUrl(url, options: options, cancelToken: cancelToken);
|
||||
final result =
|
||||
await _requestByUrl(url, options: options, cancelToken: cancelToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_utils/text_util.dart';
|
||||
@@ -19,7 +19,8 @@ class HttpRespInterceptor {
|
||||
|
||||
static Future<dynamic> handleResponse(Response response) async {
|
||||
if (response.statusCode != 200) {
|
||||
return Future.error(ApiException(response.statusCode, "statusCode is not 200"));
|
||||
return Future.error(
|
||||
ApiException(response.statusCode, "statusCode is not 200"));
|
||||
}
|
||||
BaseRespBean? baseResp;
|
||||
if (response.data is Map) {
|
||||
@@ -27,7 +28,8 @@ class HttpRespInterceptor {
|
||||
} else if (response.data is String) {
|
||||
baseResp = BaseRespBean.fromJson(json.decode(response.data));
|
||||
} else {
|
||||
return Future.error(ApiException(Code.PARSE_DATE_ERROR, Lang.PARSE_DATE_ERROR));
|
||||
return Future.error(
|
||||
ApiException(Code.PARSE_DATE_ERROR, Lang.PARSE_DATE_ERROR));
|
||||
}
|
||||
|
||||
int? code = baseResp.code;
|
||||
@@ -65,7 +67,9 @@ class HttpRespInterceptor {
|
||||
|
||||
/// 展示提示
|
||||
if (code != Code.SUCCESS) {
|
||||
if (TextUtil.isEmpty(baseResp.msg) && TextUtil.isEmpty(baseResp.tip) && response.statusCode != 200) {
|
||||
if (TextUtil.isEmpty(baseResp.msg) &&
|
||||
TextUtil.isEmpty(baseResp.tip) &&
|
||||
response.statusCode != 200) {
|
||||
showToast("服务器错误");
|
||||
} else {
|
||||
if (!TextUtil.isEmpty(baseResp.tip)) {
|
||||
@@ -75,7 +79,8 @@ class HttpRespInterceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
debugLog('path:${response.requestOptions.baseUrl}${response.requestOptions.path}');
|
||||
debugLog(
|
||||
'path:${response.requestOptions.baseUrl}${response.requestOptions.path}');
|
||||
debugLog('param: ${response.requestOptions.queryParameters}');
|
||||
debugLog('data: ${response.requestOptions.data}');
|
||||
debugLog('head: ${response.requestOptions.headers}');
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hgmj/config/address.dart';
|
||||
import 'package:hgmj/config/config.dart';
|
||||
import 'package:hgmj/hj_utils/light_model.dart';
|
||||
import 'package:hgmj/hj_utils/store_keys.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/net/aes_util.dart';
|
||||
import 'package:hgmj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgmj/tools_base/net/curl_util.dart';
|
||||
import 'package:hgmj/tools_base/net/http_manager.dart';
|
||||
import 'package:hgmj/tools_base/net/net_code.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:wydj/config/address.dart';
|
||||
import 'package:wydj/config/config.dart';
|
||||
import 'package:wydj/hj_utils/light_model.dart';
|
||||
import 'package:wydj/hj_utils/store_keys.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/net/aes_util.dart';
|
||||
import 'package:wydj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:wydj/tools_base/net/curl_util.dart';
|
||||
import 'package:wydj/tools_base/net/http_manager.dart';
|
||||
import 'package:wydj/tools_base/net/net_code.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
|
||||
/// 统一处理网络响应:解析 BaseRespBean、按业务码分发、密文解密、错误提示
|
||||
class HttpResponseInterceptor extends InterceptorsWrapper {
|
||||
@@ -25,7 +25,8 @@ class HttpResponseInterceptor extends InterceptorsWrapper {
|
||||
// curl/响应体仅 debug 下生成:generateCurl 会 json.encode 整个请求体、
|
||||
// '$data' 会序列化整个响应,无条件求值在 release 也跑、纯属浪费
|
||||
if (kDebugMode) {
|
||||
debugLog('$tag curl ====== ${CurlUtil.generateCurl(response.requestOptions)}');
|
||||
debugLog(
|
||||
'$tag curl ====== ${CurlUtil.generateCurl(response.requestOptions)}');
|
||||
debugLog('==========response==========\n${response.data}');
|
||||
}
|
||||
handleResponse(response);
|
||||
@@ -55,12 +56,14 @@ class HttpResponseInterceptor extends InterceptorsWrapper {
|
||||
try {
|
||||
baseResp = BaseRespBean.fromJson(json.decode(response.data));
|
||||
} catch (e) {
|
||||
baseResp = BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
baseResp =
|
||||
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
}
|
||||
} else if (response.data is BaseRespBean) {
|
||||
baseResp = response.data;
|
||||
} else {
|
||||
baseResp = BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
baseResp =
|
||||
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
}
|
||||
// 同步下服务器时间
|
||||
httpManager.setServerTime(baseResp.time);
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'dio_cli.dart';
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:hgmj/tools_base/net/http_manager.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_utils/light_model.dart';
|
||||
@@ -56,7 +56,8 @@ class NetManager {
|
||||
Future<String> userAgent() => DeviceInfoService.getCachedUserAgent();
|
||||
|
||||
/// 强制重新生成并写回缓存(扫码登录、设备切换等场景)。
|
||||
Future<String> refreshUserAgent() => DeviceInfoService.getCachedUserAgent(deviceId: DeviceInfoService.deviceId);
|
||||
Future<String> refreshUserAgent() => DeviceInfoService.getCachedUserAgent(
|
||||
deviceId: DeviceInfoService.deviceId);
|
||||
|
||||
/// 清除ua
|
||||
Future clearUserAgent() => DeviceInfoService.clearCachedUserAgent();
|
||||
@@ -81,7 +82,8 @@ class NetManager {
|
||||
if (TextUtil.isNotEmpty(serverTimeS)) {
|
||||
_serverTime = DateTime.parse(serverTimeS!);
|
||||
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
|
||||
debugLog("============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
debugLog(
|
||||
"============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +108,8 @@ Dio createDio({BaseOptions? options}) {
|
||||
var adapter = DefaultHttpClientAdapter();
|
||||
// var adapter = DefaultHttpClientAdapter();
|
||||
adapter.onHttpClientCreate = (client) {
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
dio.httpClientAdapter = adapter;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/tools_base/refresh/pull_refresh.dart'; // 复用 CustomRefreshView 的手感调优
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/tools_base/refresh/pull_refresh.dart'; // 复用 CustomRefreshView 的手感调优
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
/// 横向列表"拉到底查看更多"组件(手感同上拉加载更多,只是横向)。
|
||||
@@ -37,7 +37,8 @@ class HorizontalLoadMore extends StatefulWidget {
|
||||
|
||||
class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
|
||||
final RefreshController _refreshCtr = RefreshController();
|
||||
final ValueNotifier<bool> _armed = ValueNotifier(false); // 当前过拉是否已过阈值(驱动 footer 文案 + 触发判定)
|
||||
final ValueNotifier<bool> _armed =
|
||||
ValueNotifier(false); // 当前过拉是否已过阈值(驱动 footer 文案 + 触发判定)
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -49,7 +50,8 @@ class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
|
||||
// 拖动中持续更新是否已过阈值(公式同 SmartRefresher footer 的 offset 计算)
|
||||
bool _onScroll(ScrollNotification n) {
|
||||
if (n.metrics.axis != Axis.horizontal) return false;
|
||||
final double overscroll = n.metrics.pixels - n.metrics.maxScrollExtent; // 拉过末尾的距离
|
||||
final double overscroll =
|
||||
n.metrics.pixels - n.metrics.maxScrollExtent; // 拉过末尾的距离
|
||||
_armed.value = overscroll >= widget.triggerDistance;
|
||||
return false;
|
||||
}
|
||||
@@ -92,7 +94,8 @@ class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
|
||||
builder: (_, armed, ___) => _footer(armed),
|
||||
),
|
||||
),
|
||||
onLoading: () => _refreshCtr.loadComplete(), // 触发已由指针事件把关,这里只复位 footer
|
||||
onLoading: () =>
|
||||
_refreshCtr.loadComplete(), // 触发已由指针事件把关,这里只复位 footer
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
@@ -112,13 +115,15 @@ class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
|
||||
AnimatedRotation(
|
||||
turns: armed ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
|
||||
child:
|
||||
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
armed ? widget.releaseText : widget.idleText,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0x59FFFFFF), fontSize: 10, height: 1.3),
|
||||
style: const TextStyle(
|
||||
color: Color(0x59FFFFFF), fontSize: 10, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,14 +3,15 @@ import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/app_colors.dart';
|
||||
import 'package:wydj/assets_tool/app_colors.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
// 回弹弹簧:临界阻尼(≈2√(stiffness·mass)=34.6) → 松手快速收回不过冲
|
||||
// 注:实测此调优手感优于 3.16.8 包默认(mass2.2/stiffness150/damping16,欠阻尼、回弹更夸张),
|
||||
// 故采用这套调优值,不复刻 3.16.8 原值
|
||||
// 提成常量供横向的 HorizontalLoadMore 复用,两处手感必须一致,改这里即全改
|
||||
const SpringDescription kRefreshSpring = SpringDescription(mass: 1.5, stiffness: 200, damping: 35);
|
||||
const SpringDescription kRefreshSpring =
|
||||
SpringDescription(mass: 1.5, stiffness: 200, damping: 35);
|
||||
const double kRefreshDragSpeedRatio = 0.91;
|
||||
|
||||
Widget pullYsRefresh({
|
||||
@@ -106,14 +107,19 @@ class _CustomIRefreshViewState extends State<CustomRefreshView> {
|
||||
WaterDropHeader(
|
||||
waterDropColor: AppColors.actionRed,
|
||||
// iOS 刷新菊花用主题色;Android 保持默认 loading
|
||||
refresh: Platform.isIOS ? const CupertinoActivityIndicator(color: AppColors.actionRed) : null,
|
||||
refresh: Platform.isIOS
|
||||
? const CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed)
|
||||
: null,
|
||||
complete: const Text(
|
||||
"刷新完成!",
|
||||
style: TextStyle(color: AppColors.tipTextColor99, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: AppColors.tipTextColor99, fontSize: 12),
|
||||
),
|
||||
failed: const Text(
|
||||
"刷新失敗!",
|
||||
style: TextStyle(color: AppColors.tipTextColor99, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: AppColors.tipTextColor99, fontSize: 12),
|
||||
),
|
||||
),
|
||||
footer: widget.footerHeader ??
|
||||
@@ -130,7 +136,8 @@ class _CustomIRefreshViewState extends State<CustomRefreshView> {
|
||||
? const CupertinoActivityIndicator()
|
||||
: const CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.actionRed),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(AppColors.actionRed),
|
||||
),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hgmj/tools_base/debug_log.dart';
|
||||
import 'package:wydj/tools_base/debug_log.dart';
|
||||
|
||||
class HtmlProParser {
|
||||
static Color? spanBg(String? value) {
|
||||
@@ -44,7 +44,8 @@ class HtmlProParser {
|
||||
static double? lineHeight(String? value) {
|
||||
try {
|
||||
if (value?.contains("line-height:") == true) {
|
||||
final sizeStr = value!.replaceAll("line-height:", "").replaceAll(";", "");
|
||||
final sizeStr =
|
||||
value!.replaceAll("line-height:", "").replaceAll(";", "");
|
||||
return double.tryParse(sizeStr);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:mobkit_dashed_border/mobkit_dashed_border.dart';
|
||||
|
||||
class AddMediaSourceButton extends StatelessWidget {
|
||||
@@ -43,11 +43,13 @@ class AddMediaSourceButton extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
((111 - 24) / 2).sizeBoxH,
|
||||
Image.asset('add_grey.png'.communityPath, width: 24, color: const Color(0xffDCDCDC)),
|
||||
Image.asset('add_grey.png'.communityPath,
|
||||
width: 24, color: const Color(0xffDCDCDC)),
|
||||
9.sizeBoxH,
|
||||
Text(
|
||||
title ?? (isVideo ? '添加视频' : '添加图片'),
|
||||
style: TextStyle(color: const Color(0xff999999), fontSize: 14.sp),
|
||||
style:
|
||||
TextStyle(color: const Color(0xff999999), fontSize: 14.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/tools_base/widget/card_swiper/src/swiper_plugin.dart';
|
||||
import 'package:wydj/tools_base/widget/card_swiper/src/swiper_plugin.dart';
|
||||
|
||||
class SwiperControl extends SwiperPlugin {
|
||||
const SwiperControl({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgmj/assets_tool/app_colors.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/widget/common_dialog.dart';
|
||||
import 'package:wydj/assets_tool/app_colors.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/widget/common_dialog.dart';
|
||||
|
||||
/// 全站统一的提示/确认弹窗:标题 + 分割线 + 正文 + 副文案 + 灰「取消」红「确定」双按钮。
|
||||
/// 壳走 [CommonDialog],业务结果一律用返回值传(点确定=true),别在弹窗里塞跳转逻辑。
|
||||
@@ -68,7 +68,10 @@ class CommonAlert extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .9), fontSize: 20, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (showDivider) ...[
|
||||
12.sizeBoxH,
|
||||
@@ -79,7 +82,10 @@ class CommonAlert extends StatelessWidget {
|
||||
Text(
|
||||
content!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .5), fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
if (subContent?.isNotEmpty == true) ...[
|
||||
@@ -89,7 +95,8 @@ class CommonAlert extends StatelessWidget {
|
||||
child: Text(
|
||||
subContent!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: .5), fontSize: 16),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5), fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -111,7 +118,10 @@ class CommonAlert extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: const TextStyle(color: Color(0xff989898), fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -129,7 +139,10 @@ class CommonAlert extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import '../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../hj_utils/api_service/mine_service.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
|
||||
enum FollowEnum {
|
||||
@@ -64,7 +64,9 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
child: isLoading
|
||||
? _loading()
|
||||
: Image.asset(
|
||||
isFollow ? 'collect_red.png'.commonImgPath : 'collect_path.png'.commonImgPath,
|
||||
isFollow
|
||||
? 'collect_red.png'.commonImgPath
|
||||
: 'collect_path.png'.commonImgPath,
|
||||
width: 24,
|
||||
),
|
||||
);
|
||||
@@ -86,11 +88,17 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
isLoading ? _loading() : const Icon(Icons.check, size: 14, color: Color(0xff989898)),
|
||||
isLoading
|
||||
? _loading()
|
||||
: const Icon(Icons.check,
|
||||
size: 14, color: Color(0xff989898)),
|
||||
4.sizeBoxW,
|
||||
const Text(
|
||||
'关注',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xff989898)),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xff989898)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -104,11 +112,17 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
isLoading ? _loading() : const Icon(Icons.add, size: 14, color: AppColors.actionRed),
|
||||
isLoading
|
||||
? _loading()
|
||||
: const Icon(Icons.add,
|
||||
size: 14, color: AppColors.actionRed),
|
||||
4.sizeBoxW,
|
||||
const Text(
|
||||
'关注',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.actionRed),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.actionRed),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,20 +146,25 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: isFollow
|
||||
? Text('已收藏', style: textStyle(10, Colors.white, FontWeight.w400))
|
||||
? Text('已收藏',
|
||||
style: textStyle(10, Colors.white, FontWeight.w400))
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('add_red.png'.commonImgPath, width: 16, color: Colors.black),
|
||||
Image.asset('add_red.png'.commonImgPath,
|
||||
width: 16, color: Colors.black),
|
||||
2.sizeBoxW,
|
||||
Text('收藏', style: textStyle(10, Colors.black, FontWeight.w400)),
|
||||
Text('收藏',
|
||||
style:
|
||||
textStyle(10, Colors.black, FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loading() => const CupertinoActivityIndicator(color: Colors.white, radius: 8);
|
||||
Widget _loading() =>
|
||||
const CupertinoActivityIndicator(color: Colors.white, radius: 8);
|
||||
|
||||
//加入书架 / 取消收藏
|
||||
void _onCollectTap() => isFollow ? _unCollect() : _collect();
|
||||
@@ -153,7 +172,8 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
void _unCollect() async {
|
||||
if (isLoading) return;
|
||||
setState(() => isLoading = true);
|
||||
if (await ACGService.deleteBookshelf(mediaId ?? '')) widget.onSuccess?.call(false);
|
||||
if (await ACGService.deleteBookshelf(mediaId ?? ''))
|
||||
widget.onSuccess?.call(false);
|
||||
isLoading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@@ -161,7 +181,8 @@ class _FollowButtonState extends State<FollowButton> {
|
||||
void _collect() async {
|
||||
if (isLoading) return;
|
||||
setState(() => isLoading = true);
|
||||
if (await ACGService.addBookshelf(mediaId ?? '')) widget.onSuccess?.call(true);
|
||||
if (await ACGService.addBookshelf(mediaId ?? ''))
|
||||
widget.onSuccess?.call(true);
|
||||
isLoading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:wydj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
///用户头像
|
||||
class HeaderWidget extends StatelessWidget {
|
||||
@@ -27,7 +27,8 @@ class HeaderWidget extends StatelessWidget {
|
||||
height: headHeight,
|
||||
decoration: isCircle
|
||||
? const BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(width: 4, color: Color.fromRGBO(253, 45, 85, 1))),
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(width: 4, color: Color.fromRGBO(253, 45, 85, 1))),
|
||||
shape: BoxShape.circle,
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgmj/assets_tool/app_colors.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/image_util.dart';
|
||||
import 'package:hgmj/tools_base/cache/image_cache_manager.dart';
|
||||
import 'package:hgmj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgmj/tools_base/toast.dart';
|
||||
import 'package:hgmj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:wydj/assets_tool/app_colors.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/image_util.dart';
|
||||
import 'package:wydj/tools_base/cache/image_cache_manager.dart';
|
||||
import 'package:wydj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:wydj/tools_base/toast.dart';
|
||||
import 'package:wydj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
/// 微信风格图片浏览器
|
||||
/// 横滑切图 + 双指/双击缩放 + 上下滑拖拽退出(图片跟手、背景随拖动渐隐露出下层页面) + 长按保存
|
||||
@@ -20,18 +20,26 @@ class ImageBrowserPage extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
final bool showSaveButton; // 右上角显式"保存到相册"按钮(AI 生成图等场景);默认只支持长按保存
|
||||
|
||||
const ImageBrowserPage({super.key, required this.images, this.initialIndex = 0, this.showSaveButton = false});
|
||||
const ImageBrowserPage(
|
||||
{super.key,
|
||||
required this.images,
|
||||
this.initialIndex = 0,
|
||||
this.showSaveButton = false});
|
||||
|
||||
/// 打开浏览器(透明路由 + 淡入)
|
||||
/// [showSaveButton] 显示右上角"保存到相册"按钮
|
||||
static void open(List<String> images, {int index = 0, bool showSaveButton = false}) {
|
||||
static void open(List<String> images,
|
||||
{int index = 0, bool showSaveButton = false}) {
|
||||
final valid = images.where((e) => e.isNotEmpty).toList();
|
||||
if (valid.isEmpty) return;
|
||||
// fullscreenDialog:true → GetX canTransitionTo 返回 false,下层页面不做外出转场,
|
||||
// 保持完整渲染 → 拖拽偷看时看到的是整页而非"pop 一半"。
|
||||
// 瞬时 fadeIn:打开无残影。仍是 Get.to → Get.back 能正常 pop,下滑退出正常
|
||||
Get.to(
|
||||
() => ImageBrowserPage(images: valid, initialIndex: index.clamp(0, valid.length - 1), showSaveButton: showSaveButton),
|
||||
() => ImageBrowserPage(
|
||||
images: valid,
|
||||
initialIndex: index.clamp(0, valid.length - 1),
|
||||
showSaveButton: showSaveButton),
|
||||
opaque: false,
|
||||
transition: Transition.fadeIn,
|
||||
duration: Duration.zero,
|
||||
@@ -43,7 +51,8 @@ class ImageBrowserPage extends StatefulWidget {
|
||||
State<ImageBrowserPage> createState() => _ImageBrowserPageState();
|
||||
}
|
||||
|
||||
class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProviderStateMixin {
|
||||
class _ImageBrowserPageState extends State<ImageBrowserPage>
|
||||
with TickerProviderStateMixin {
|
||||
// ========== 分页 ==========
|
||||
late final PageController _pageCtr;
|
||||
late int _curIndex;
|
||||
@@ -72,11 +81,17 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
super.initState();
|
||||
_curIndex = widget.initialIndex;
|
||||
_pageCtr = PageController(initialPage: _curIndex);
|
||||
_transCtrs = List.generate(widget.images.length, (_) => TransformationController());
|
||||
_resetCtr = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_zoomCtr = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_enterCtr = AnimationController(vsync: this, duration: const Duration(milliseconds: 220))..forward();
|
||||
_exitCtr = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_transCtrs =
|
||||
List.generate(widget.images.length, (_) => TransformationController());
|
||||
_resetCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_zoomCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_enterCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 220))
|
||||
..forward();
|
||||
_exitCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,10 +112,12 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
bool get _isZoomed => _transCtrs[_curIndex].value.getMaxScaleOnAxis() > 1.05;
|
||||
|
||||
// 背景不透明度:随竖直拖动距离渐隐(下拉偷看下层)
|
||||
double get _bgOpacity => (1 - _dragY.abs() / (Get.height * 0.6)).clamp(0.0, 1.0);
|
||||
double get _bgOpacity =>
|
||||
(1 - _dragY.abs() / (Get.height * 0.6)).clamp(0.0, 1.0);
|
||||
|
||||
// 拖动时图片轻微缩小
|
||||
double get _dragScale => (1 - _dragY.abs() / (Get.height * 2)).clamp(0.85, 1.0);
|
||||
double get _dragScale =>
|
||||
(1 - _dragY.abs() / (Get.height * 2)).clamp(0.85, 1.0);
|
||||
|
||||
// 退出淡出系数:1→0
|
||||
double get _exitFactor => 1 - _exitCtr.value;
|
||||
@@ -119,8 +136,8 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
_exit();
|
||||
return;
|
||||
}
|
||||
final anim =
|
||||
Tween<double>(begin: _dragY, end: 0).animate(CurvedAnimation(parent: _resetCtr, curve: Curves.easeOut));
|
||||
final anim = Tween<double>(begin: _dragY, end: 0)
|
||||
.animate(CurvedAnimation(parent: _resetCtr, curve: Curves.easeOut));
|
||||
void listener() => setState(() => _dragY = anim.value);
|
||||
anim.addListener(listener);
|
||||
// whenComplete 在 dispose 取消 ticker 时也会触发,一律先判 mounted
|
||||
@@ -156,8 +173,8 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
-pos.dx * (scale - 1), -pos.dy * (scale - 1), 0, 1, //
|
||||
);
|
||||
}
|
||||
final anim =
|
||||
Matrix4Tween(begin: ctr.value, end: target).animate(CurvedAnimation(parent: _zoomCtr, curve: Curves.easeOut));
|
||||
final anim = Matrix4Tween(begin: ctr.value, end: target)
|
||||
.animate(CurvedAnimation(parent: _zoomCtr, curve: Curves.easeOut));
|
||||
void listener() => ctr.value = anim.value;
|
||||
anim.addListener(listener);
|
||||
_zoomCtr.forward(from: 0).whenComplete(() {
|
||||
@@ -195,7 +212,10 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
builder: (_, __) => Stack(
|
||||
children: [
|
||||
// 黑色背景,随拖动/退出渐隐
|
||||
Positioned.fill(child: ColoredBox(color: Colors.black.withValues(alpha: _bgOpacity * _exitFactor))),
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black
|
||||
.withValues(alpha: _bgOpacity * _exitFactor))),
|
||||
GestureDetector(
|
||||
onLongPress: _saveCurrent, // 长按保存
|
||||
onDoubleTapDown: (d) => _tapPos = d.localPosition,
|
||||
@@ -209,7 +229,8 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
opacity: _enterCtr,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragY),
|
||||
child: Transform.scale(scale: _dragScale, child: _pageView()),
|
||||
child:
|
||||
Transform.scale(scale: _dragScale, child: _pageView()),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -226,7 +247,9 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
if (_isSaving)
|
||||
const Positioned.fill(
|
||||
child: AbsorbPointer(
|
||||
child: ColoredBox(color: Color(0x80000000), child: LoadingAlertWidget(title: '保存中...')),
|
||||
child: ColoredBox(
|
||||
color: Color(0x80000000),
|
||||
child: LoadingAlertWidget(title: '保存中...')),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -272,7 +295,8 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
right: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -287,7 +311,8 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
: (isIncoming ? const Offset(0, -1) : const Offset(0, 1));
|
||||
return ClipRect(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(begin: begin, end: Offset.zero).animate(anim),
|
||||
position: Tween<Offset>(begin: begin, end: Offset.zero)
|
||||
.animate(anim),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
@@ -295,10 +320,12 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
child: Text(
|
||||
'${_curIndex + 1}',
|
||||
key: ValueKey(_curIndex),
|
||||
style: const TextStyle(color: AppColors.actionRed, fontSize: 12),
|
||||
style:
|
||||
const TextStyle(color: AppColors.actionRed, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Text('/${widget.images.length}', style: const TextStyle(color: Color(0xff3D3D3D), fontSize: 12)),
|
||||
Text('/${widget.images.length}',
|
||||
style: const TextStyle(color: Color(0xff3D3D3D), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -331,8 +358,11 @@ class _ImageBrowserPageState extends State<ImageBrowserPage> with TickerProvider
|
||||
onTap: _saveCurrent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: const Color(0xFFE57310)),
|
||||
child: const Text('保存到相册', style: TextStyle(color: Colors.white, fontSize: 14)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: const Color(0xFFE57310)),
|
||||
child: const Text('保存到相册',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
|
||||
/// 双击点赞爱心动效的触发器。onDoubleTapDown 里 [markAt] 记落点,onDoubleTap 里 [burst] 放动画
|
||||
class LikeBurstController extends ChangeNotifier {
|
||||
@@ -31,16 +31,19 @@ class LikeBurstView extends StatefulWidget {
|
||||
State<LikeBurstView> createState() => _LikeBurstViewState();
|
||||
}
|
||||
|
||||
class _LikeBurstViewState extends State<LikeBurstView> with SingleTickerProviderStateMixin {
|
||||
class _LikeBurstViewState extends State<LikeBurstView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctr;
|
||||
Offset? _at;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctr = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
|
||||
_ctr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 600))
|
||||
..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && mounted) setState(() => _at = null);
|
||||
if (status == AnimationStatus.completed && mounted)
|
||||
setState(() => _at = null);
|
||||
});
|
||||
widget.controller.addListener(_onBurst);
|
||||
}
|
||||
@@ -81,8 +84,10 @@ class _LikeBurstViewState extends State<LikeBurstView> with SingleTickerProvider
|
||||
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 40),
|
||||
]).animate(_ctr),
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.5, end: 1.3).animate(CurvedAnimation(parent: _ctr, curve: Curves.easeOutBack)),
|
||||
child: Image.asset(widget.icon.commonImgPath, width: widget.size, height: widget.size),
|
||||
scale: Tween(begin: 0.5, end: 1.3).animate(
|
||||
CurvedAnimation(parent: _ctr, curve: Curves.easeOutBack)),
|
||||
child: Image.asset(widget.icon.commonImgPath,
|
||||
width: widget.size, height: widget.size),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgmj/assets_tool/images.dart';
|
||||
import 'package:hgmj/hj_utils/screen.dart';
|
||||
import 'package:hgmj/tools_base/cache/image_cache_manager.dart';
|
||||
import 'package:wydj/assets_tool/images.dart';
|
||||
import 'package:wydj/hj_utils/screen.dart';
|
||||
import 'package:wydj/tools_base/cache/image_cache_manager.dart';
|
||||
|
||||
class NetworkImageLoader extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
@@ -55,12 +55,15 @@ class NetworkImageLoader extends StatelessWidget {
|
||||
fadeOutCurve: Curves.linear,
|
||||
);
|
||||
final radius = imgBorderRadius ?? BorderRadius.circular(borderRadius);
|
||||
if (borderRadius != 0) image = ClipRRect(borderRadius: radius, child: image);
|
||||
if (borderRadius != 0)
|
||||
image = ClipRRect(borderRadius: radius, child: image);
|
||||
//模糊会溢出边界,外面再裁一层
|
||||
if (blur) {
|
||||
image = ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: ImageFiltered(imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: image),
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: image),
|
||||
);
|
||||
}
|
||||
return image;
|
||||
@@ -72,7 +75,9 @@ class NetworkImageLoader extends StatelessWidget {
|
||||
final dpr = screen.devicePixelRatio;
|
||||
if (!isResizeImage) return (screen.screenWidth * dpr).toInt();
|
||||
//约束无限时用屏幕宽兜底,避免退化成按原图解码(图片过载)
|
||||
final w = (cons.maxWidth == double.infinity || cons.maxWidth.isNaN) ? screen.screenWidth : cons.maxWidth;
|
||||
final w = (cons.maxWidth == double.infinity || cons.maxWidth.isNaN)
|
||||
? screen.screenWidth
|
||||
: cons.maxWidth;
|
||||
return (w * dpr).toInt();
|
||||
}
|
||||
|
||||
@@ -82,7 +87,8 @@ class NetworkImageLoader extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: const BoxDecoration(color: Color(0xff151E2D)),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset('place_holder_logo.webp'.commonImgPath, width: 100, height: 35),
|
||||
child: Image.asset('place_holder_logo.webp'.commonImgPath,
|
||||
width: 100, height: 35),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,14 @@ import 'dart:math';
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hgmj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
|
||||
import 'package:hgmj/tools_base/widget/scroll_positioned_list/src/item_positions_notifier.dart';
|
||||
import 'package:hgmj/tools_base/widget/scroll_positioned_list/src/scroll_offset_listener.dart';
|
||||
import 'package:wydj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
|
||||
import 'package:wydj/tools_base/widget/scroll_positioned_list/src/item_positions_notifier.dart';
|
||||
import 'package:wydj/tools_base/widget/scroll_positioned_list/src/scroll_offset_listener.dart';
|
||||
|
||||
import 'positioned_list.dart';
|
||||
import 'post_mount_callback.dart';
|
||||
import 'scroll_offset_notifier.dart';
|
||||
|
||||
|
||||
/// Number of screens to scroll when scrolling a long distance.
|
||||
const int _screenScrollCount = 2;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user