logo和名称替换

This commit is contained in:
谢宇宁
2026-10-14 11:23:36 +07:00
parent 1cb514cd02
commit c5c04a7967
478 changed files with 8990 additions and 4948 deletions
+27 -16
View File
@@ -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()); // 先清掉旧广告
+39 -15
View File
@@ -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 -2
View File
@@ -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';
+29 -11
View File
@@ -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))),
),
],
);
+18 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+20 -13
View File
@@ -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) {
+6 -5
View File
@@ -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,
+5 -4
View File
@@ -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),
),
]
],
+4 -4
View File
@@ -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) {
+68 -32
View File
@@ -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;
}
}
+11 -6
View File
@@ -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';
+8 -5
View File
@@ -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),
),
],
),
+13 -6
View File
@@ -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({
+21 -8
View File
@@ -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),
),
),
),
+35 -14
View File
@@ -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(() {});
}
+3 -2
View File
@@ -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,
+61 -31
View File
@@ -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)),
),
),
),
+11 -6
View File
@@ -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),
),
),
),
+13 -7
View File
@@ -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;