初始化
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
//acg模块网络请求
|
||||
|
||||
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../hj_model/acg/cartoon_more_list.dart';
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_model/media_content.dart';
|
||||
|
||||
class ACGService {
|
||||
//获取动漫详情 种类 1、动漫,2、漫画
|
||||
static Future<CartoonMediaInfo?> getMediaInfo(String id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/info',
|
||||
param: param,
|
||||
jsonTransformation: (json) => CartoonMediaInfo.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///动漫更多数据
|
||||
static Future<ListBaseModel<CartoonMediaInfo>?> getMoreCartoon(
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
int sortType, //排序 1最多播放,2、最新,3、 最多收藏 "
|
||||
String? tagID,
|
||||
) async {
|
||||
final param = {
|
||||
'tagID': tagID,
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'sortType': sortType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
"/tag/media/list",
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<CartoonMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//加入书架
|
||||
static Future<bool> addBookshelf(String id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/media_bookshelf/add',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// 获取标签详情
|
||||
static Future<TagsBean?> fetchMediaTag(String tagId) async {
|
||||
final param = {'id': tagId};
|
||||
final result = await httpManager.fetchResponseByGET('/media_tag/info',
|
||||
param: param, jsonTransformation: (json) => TagsBean.fromMap(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//移除书架
|
||||
static Future<bool> deleteBookshelf(String id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/media_bookshelf/del',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//acg热门推荐 0-最新热播 1-本月最热 2-上月最热
|
||||
static Future<ListBaseModel<CartoonMediaInfo>?> fetchRecommend(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
String? mediaType = 'video',
|
||||
int? type = 0,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'mediaType': mediaType,
|
||||
'type': type,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/hot',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<CartoonMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取媒体书架列表 //排序 0、最新收藏 1.最近更新
|
||||
static Future<ListBaseModel<T>?> fetchAcgBooklib<T>(
|
||||
String type, {
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'type': type,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_bookshelf/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<T>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<ListBaseModel<T>?> fetchAcgBuyData<T>(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
String? mediaType = 'video',
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'mediaType': mediaType,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/my_buy',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<T>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//acg日/周/人气等榜单 1-日榜 2-周榜 3-人气榜 4-热度榜 5-钻石榜 6-连载榜
|
||||
|
||||
static Future<ListBaseModel<CartoonMediaInfo>?> getRanking(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
String? mediaType = 'video', //video 动画 image 漫画 text 小说
|
||||
int? type = 1,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'mediaType': mediaType,
|
||||
'type': type,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/ranking',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<CartoonMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取动漫内容章节列表 sortType: 0-正序 1-倒序
|
||||
|
||||
static Future<ListBaseModel<ComicChapterInfo>?> getChapterList(
|
||||
String mediaId, int pageNumber, int pageSize,
|
||||
{int? sortType = 0}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'mediaId': mediaId,
|
||||
'sortType': sortType,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_content/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<ComicChapterInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//动漫详情-推荐列表,相似作品
|
||||
static Future<ListBaseModel<CartoonMediaInfo>?> comicsRecommendList(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
String? tagId, //标签id
|
||||
String? mediaType, // 视频播放页时-必传 ACG类型: image, video
|
||||
String? mediaId, //标签id
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'tagId': tagId,
|
||||
'mediaType': mediaType,
|
||||
'mediaId': mediaId,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null); //去掉 null,避免发出 tagId=null 等脏参数
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/recommend',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<CartoonMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//kind:1动漫 2漫画 3小说 4短剧
|
||||
//sortType:1-最新上架 2-最多观看
|
||||
static Future<MediaSearchListModel?> mediaSearch(
|
||||
{int page = 1,
|
||||
int size = 20,
|
||||
int? kind,
|
||||
String? tagName,
|
||||
int? sortType,
|
||||
String? keyword}) async {
|
||||
final param = {
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'kind': kind,
|
||||
'tagName': tagName,
|
||||
'sortType': sortType,
|
||||
'keyword': keyword
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/search',
|
||||
param: param,
|
||||
jsonTransformation: (json) => MediaSearchListModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取子集详情
|
||||
static Future<MediaContent?> getMediaDetail({
|
||||
String? id,
|
||||
int? episodeNumber,
|
||||
String? mediaId,
|
||||
}) async {
|
||||
final param = {
|
||||
'id': id,
|
||||
'episodeNumber': episodeNumber,
|
||||
'mediaId': mediaId,
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_content/info',
|
||||
param: param,
|
||||
jsonTransformation: (json) => MediaContent.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///动漫更多数据
|
||||
static Future<ListBaseModel<CartoonMediaInfo>?> getPreferences(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
String sId = '',
|
||||
int? sortType = 0, //排序 1-最新 2-最多观看 3-最多喜欢(收藏)
|
||||
int? type = 1, //0-默认专题这些页面获取 1-首页获取(猜你喜欢)
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'sId': sId,
|
||||
'sortType': sortType,
|
||||
'type': type,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/topic',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<CartoonMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//AI相关接口
|
||||
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_page/ai/ai_sub_type/ai_function_logic.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../hj_page/ai/models/ai_girl_resp_model.dart';
|
||||
import '../../hj_page/ai/models/ai_mod_list_model.dart';
|
||||
import '../../hj_page/ai/models/ai_record_model.dart';
|
||||
import '../../hj_page/ai/models/ai_square_model.dart';
|
||||
|
||||
class AIService {
|
||||
// 获取AI脱衣记录列表
|
||||
//status 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getUndressList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/undress/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取AI照片换脸记录列表
|
||||
// 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getImgList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/img/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取AI视频换脸记录列表
|
||||
// 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getChangeFaceList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/changeface/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//生成AI脱衣记录
|
||||
static Future<bool> generateUndress(
|
||||
List<String>? originPics, bool shareStatus, String? shareTitle) async {
|
||||
final param = {
|
||||
'originPic': originPics,
|
||||
'shareStatus': shareStatus ? 1 : 0,
|
||||
'shareTitle': shareTitle
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ai/undress/generate',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//生成AI视频换脸
|
||||
static Future<bool> generateChangeFace(
|
||||
List<String>? picture,
|
||||
String? vidModId,
|
||||
String? discountId,
|
||||
bool shareStatus,
|
||||
String? shareTitle,
|
||||
) async {
|
||||
final param = {
|
||||
'picture': picture,
|
||||
'vidModId': vidModId,
|
||||
'discount': discountId?.isNotEmpty == true ? [discountId!] : null,
|
||||
'shareStatus': shareStatus ? 1 : 0,
|
||||
'shareTitle': shareTitle
|
||||
}..removeWhere((k, v) => v == null);
|
||||
|
||||
final result = await httpManager
|
||||
.fetchResponseByPOST('/ai/changeface/generate', param: param);
|
||||
return result.isSuccess && result.data == 'success';
|
||||
}
|
||||
|
||||
//生成图片AI换脸
|
||||
static Future<bool> generateImg(
|
||||
String picture, String imgModId, bool shareStatus, String? shareTitle,
|
||||
{String? discount}) async {
|
||||
final param = {
|
||||
'originPic': picture,
|
||||
'mId': imgModId,
|
||||
'discount': discount,
|
||||
'shareStatus': shareStatus ? 1 : 0,
|
||||
'shareTitle': shareTitle
|
||||
};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ai/img/generate',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess && result.data == 'success';
|
||||
}
|
||||
|
||||
//ai脱衣
|
||||
static Future<AiModList?> getModelList() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/mod/list',
|
||||
param: {},
|
||||
jsonTransformation: (json) => AiModList.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//ai换脸 新版本
|
||||
static Future<AiChangeModList?> getModelListV2({
|
||||
AiType type = AiType.imageChangeFace, //只认图片换脸/视频换脸两种
|
||||
String? categoryId, //模版分类id,如果为空,则默认第一个模版分类
|
||||
}) async {
|
||||
//接口只认 type=0/1,枚举在这一层换回去
|
||||
final param = {'type': type.isVideoFace ? 1 : 0, 'categoryId': categoryId}
|
||||
..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/mod/v2/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => AiChangeModList.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///删除AI订单脱衣
|
||||
static Future<bool> deleteUndress(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result =
|
||||
await httpManager.fetchResponseByPOST('/ai/undress/hide', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
///删除AI换脸Video订单
|
||||
static Future<bool> deleteChangeFace(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager.fetchResponseByPOST('/ai/changeface/hide',
|
||||
param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
///删除AI换脸 Img订单
|
||||
static Future<bool> deleteImg(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result =
|
||||
await httpManager.fetchResponseByPOST('/ai/img/hide', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//ai女友用户余额查询
|
||||
static Future<AIGirlFriendBalanceModel> getBalance(data) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/aimate/getBalance',
|
||||
param: {},
|
||||
jsonTransformation: (value) => AIGirlFriendBalanceModel.fromJson(value),
|
||||
);
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/// 获取 AI 女友 H5 链接(v2,点击入口时请求)
|
||||
static Future<AIGirlFriendUrlModel?> getMateUrl([dynamic data]) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/aimatev2/url',
|
||||
jsonTransformation: (value) => AIGirlFriendUrlModel.fromJson(value),
|
||||
);
|
||||
return result.isSuccess ? result.data as AIGirlFriendUrlModel? : null;
|
||||
}
|
||||
|
||||
//获取AI女友货币列表
|
||||
static Future<AIGirlFriendCurrencys> getMateCurrencies() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/aimate/currencys',
|
||||
param: {},
|
||||
jsonTransformation: (value) => AIGirlFriendCurrencys.fromJson(value),
|
||||
);
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
//AI女友积分兑换
|
||||
static Future<dynamic> exchangeMate(data) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/aimate/exchange',
|
||||
param: data,
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///获取AI广场列表
|
||||
static Future<ListBaseModel<AISquareItemModel>?> getPlazaList(int pageNumber,
|
||||
{int pageSize = 10}) async {
|
||||
final result = await httpManager.fetchResponseByGET('/aiplaza/list',
|
||||
param: {'pageNumber': pageNumber, 'pageSize': pageSize},
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<AISquareItemModel>.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///Ai模版是否还存在
|
||||
static Future<TemplateModel?> getModelInfo(String? id, int? type) async {
|
||||
final param = {'id': id, 'type': type};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final resultData =
|
||||
await httpManager.fetchResponseByGET('/ai/mod/info', param: param);
|
||||
if (resultData.isSuccess) {
|
||||
final data = resultData.data;
|
||||
if (data is Map) {
|
||||
try {
|
||||
//模版被下架时 aiChangeFaceMod 为 null,要回 null 让调用方走「已下架」分支
|
||||
final mod = data['aiChangeFaceMod'];
|
||||
resultData.data = mod == null ? null : TemplateModel.fromJson(mod);
|
||||
} catch (e) {
|
||||
debugLog('AIService', 'getModelInfo 解析失败: $e');
|
||||
resultData.data = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return resultData.data;
|
||||
}
|
||||
|
||||
// 获取文字绘图记录
|
||||
//status 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getTextToImageList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/text_to_image/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///删除文字绘图
|
||||
static Future<bool> deleteTextToImage(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager
|
||||
.fetchResponseByPOST('/ai/text_to_image/hide', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
// 获取AI图生视频记录列表
|
||||
//status 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getImgVideoList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai/imagetovideo/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///删除AI图生视频
|
||||
static Future<bool> deleteImgVideo(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager
|
||||
.fetchResponseByPOST('/ai/imagetovideo/hide', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// ai 生成小说
|
||||
static Future<bool> generateNovel(
|
||||
String description, {
|
||||
//剧情描述/故事情节
|
||||
String? characterSetting, //人物设定
|
||||
String? details, //细节说明/其他要求
|
||||
String? locationScene, //地点场景
|
||||
int modelType = 1, //1:AI小艺 2:AI小萌
|
||||
}) async {
|
||||
final param = {
|
||||
'details': details,
|
||||
"locationScene": locationScene,
|
||||
"modelType": modelType,
|
||||
'description': description,
|
||||
'characterSetting': characterSetting
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ai_text_to_novel/generate',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
///生成AI图生视频记录
|
||||
static Future<bool> generateImgVideo(
|
||||
String originPic, {
|
||||
bool shareStatus = true,
|
||||
String? shareTitle,
|
||||
String? mid,
|
||||
}) async {
|
||||
final param = {
|
||||
'originPic': originPic,
|
||||
'shareStatus': shareStatus ? 1 : 0,
|
||||
'shareTitle': shareTitle,
|
||||
'mid': mid,
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ai/imagetovideo/generate',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// ai文生图
|
||||
static Future<bool> generateTextToImage(
|
||||
String aspectRatio,
|
||||
int styleType,
|
||||
String text, {
|
||||
bool shareStatus = true,
|
||||
String? shareTitle,
|
||||
}) async {
|
||||
final param = {
|
||||
'aspectRatio': aspectRatio,
|
||||
"styleType": styleType,
|
||||
"text": text,
|
||||
'shareStatus': shareStatus ? 1 : 0,
|
||||
'shareTitle': shareTitle
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ai/text_to_image/generate',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
// 获取AI生成小说记录列表
|
||||
//status 1、进行中 2、生成成功 3、生成失败
|
||||
static Future<ListBaseModel<AiRecordModel>?> getNovelList(
|
||||
int? pageNumber,
|
||||
int? pageSize,
|
||||
int? status,
|
||||
) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'status': status
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ai_text_to_novel/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<AiRecordModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///删除AI文生图
|
||||
static Future<bool> deleteNovel(String? id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager
|
||||
.fetchResponseByPOST('/ai_text_to_novel/hide', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 购买相关接口
|
||||
class BuyService {
|
||||
/// /product/buy 一律带 X-Request-ID。
|
||||
/// [requestId] 由调用方持有时才是幂等键:一笔业务被拆成多次请求时(如短剧「充值成功后
|
||||
/// 自动补一次解锁」)复用同一个 id,服务端直接回首次结果,不会重复扣费。
|
||||
/// 不传就现生成一个,只作服务端链路追踪用——连点下单由 PayManager 的全屏 loading 挡着
|
||||
static Options _idempotent(String? requestId) => Options(
|
||||
headers: {'X-Request-ID': requestId ?? const Uuid().v4()},
|
||||
);
|
||||
|
||||
///购买视频 [productType] 见 PayManager 的 ProductType
|
||||
///[contentID] 子内容ID(短剧买单集时传这一集的 id,productID 仍是剧 id)
|
||||
///[checkoutContextId] 付费墙下发的结算上下文,服务端据此对账/归因
|
||||
///[requestId] 幂等键,见 [_idempotent];不传则自动生成一个仅供追踪的 id
|
||||
///[jsonTransformation] 需要把返回体解析成模型时传,不传则 data 为原始 json
|
||||
static Future<BaseRespBean> buyVideo(
|
||||
String? productID,
|
||||
int? productType, {
|
||||
String? couponId, // 优惠卷ID
|
||||
int? goldVideoCouponNum, //金币视频抵用券金币面值,
|
||||
String? serviceId, // 裸聊服务ID
|
||||
String? source, // 来源页面标识
|
||||
String? contentID,
|
||||
String? checkoutContextId,
|
||||
String? requestId,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'productID': productID,
|
||||
'productType': productType,
|
||||
'contentID': contentID,
|
||||
'checkoutContextId': checkoutContextId,
|
||||
'couponId': couponId,
|
||||
'goldVideoCouponNum': goldVideoCouponNum,
|
||||
'serviceId': serviceId,
|
||||
'source': source,
|
||||
'isH5': false,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
return httpManager.fetchResponseByPOST(
|
||||
'/product/buy',
|
||||
param: param,
|
||||
options: _idempotent(requestId),
|
||||
jsonTransformation: jsonTransformation,
|
||||
);
|
||||
}
|
||||
|
||||
///金币购买vip
|
||||
///[finalPayStatus]: 预售业务,true 付尾款, false 付预定款;null 非预售业务;
|
||||
///[couponId] 金币加赠券券id
|
||||
///[source] 来源页面标识
|
||||
///[experimentId]/[experimentVariant]/[sessionId] VIP 卡皮 A/B:与 /mine/topay 对齐回传
|
||||
///[requestId] 幂等键,见 [_idempotent]
|
||||
static Future<BaseRespBean> buyVip(int? productType, String? productID,
|
||||
String? productName, int? discountedPrice,
|
||||
{String? couponId = "",
|
||||
bool? finalPayStatus,
|
||||
String? source,
|
||||
String? experimentId,
|
||||
String? experimentVariant,
|
||||
String? sessionId,
|
||||
String? mediaId,
|
||||
String? contentId,
|
||||
String? checkoutContextId,
|
||||
String? requestId}) async {
|
||||
final param = <String, dynamic>{
|
||||
'productType': productType,
|
||||
'productID': productID,
|
||||
'productName': productName,
|
||||
'discountedPrice': discountedPrice,
|
||||
'couponId': couponId,
|
||||
'finalPayStatus': finalPayStatus,
|
||||
'source': source,
|
||||
// 短剧付费墙开卡归因,字段名与 /mine/topay 那套对齐
|
||||
'mediaId': mediaId,
|
||||
'contentId': contentId,
|
||||
'checkoutContextId': checkoutContextId,
|
||||
// 空值不传,避免参数错误(DISABLED / 无实验时上游不填)
|
||||
'experimentId': (experimentId != null && experimentId.isNotEmpty)
|
||||
? experimentId
|
||||
: null,
|
||||
'experimentVariant':
|
||||
(experimentVariant != null && experimentVariant.isNotEmpty)
|
||||
? experimentVariant
|
||||
: null,
|
||||
'sessionId':
|
||||
(sessionId != null && sessionId.isNotEmpty) ? sessionId : null,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
return httpManager.fetchResponseByPOST('/product/buy',
|
||||
param: param, options: _idempotent(requestId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//评论相关接口
|
||||
|
||||
import 'package:hgdj/hj_model/comment/comment_list_res.dart';
|
||||
import 'package:hgdj/hj_model/comment/comment_model.dart';
|
||||
import 'package:hgdj/hj_model/comment/reply_model.dart';
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
class CommentService {
|
||||
// //一级评论的全部列表
|
||||
static Future<CommentListRes?> getCommentList(
|
||||
String? objID,
|
||||
String? curTime,
|
||||
int? pageNumber,
|
||||
int? pageSize, {
|
||||
String? objType, //video:视频(默认) cartoon:动漫 section:专题
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'objID': objID,
|
||||
'curTime': curTime,
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'objType': objType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/comment/list',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess ? CommentListRes.fromJson(result.data) : null;
|
||||
}
|
||||
|
||||
//发表评论(1)
|
||||
//Address.SEND_COMMENT) objType 评论对象类型 video:视频(默认) cartoon:动漫 section:专题 driftBottle:树洞纸条
|
||||
// quote这个是评论引用资源,如视频,图集等数据,需要传quote相关数据。
|
||||
static Future<CommentModel?> sendComment(
|
||||
String? objID,
|
||||
int? level,
|
||||
String? content,
|
||||
String objType, {
|
||||
String? quoteID, //引用id
|
||||
String? quoteImg, //引用资源图片
|
||||
String? quoteTitle, //引用资源标题
|
||||
String? quoteType, //引用类型 vid视频 col用户播单 sec官方播单
|
||||
String? image, // 图片资源
|
||||
}) async {
|
||||
final param = {
|
||||
'objID': objID,
|
||||
'level': level,
|
||||
'content': content,
|
||||
"objType": objType,
|
||||
"quoteID": quoteID,
|
||||
"quoteImg": quoteImg,
|
||||
"quoteTitle": quoteTitle,
|
||||
"quoteType": quoteType,
|
||||
'image': image,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/comment/send',
|
||||
param: param,
|
||||
);
|
||||
if (result.isSuccess && result.data is Map) {
|
||||
return CommentModel.fromJson(result.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 回复
|
||||
///sendReply 回复评论,
|
||||
///
|
||||
///[quoteID] 引用资源id
|
||||
///[objType] 是业务类型, 根据项目业务来定。
|
||||
///[cid](评论id), [rid](回复id), [toUserID] (回复评论人的id), 只有回复评论才需要这个参数。
|
||||
///[objID] 对象id(如视频详情,传视频详情id)。
|
||||
///评论 [level] = 1; 回复 [level] = 2
|
||||
// */
|
||||
static Future<ReplyModel?> sendReply(
|
||||
String? objID,
|
||||
int? level,
|
||||
String? content, {
|
||||
String? cid,
|
||||
String? rid,
|
||||
int? toUserID,
|
||||
dynamic objType,
|
||||
String? quoteID,
|
||||
String? quoteImg,
|
||||
String? quoteTitle,
|
||||
String? quoteType,
|
||||
String? image, // 图片资源
|
||||
}) async {
|
||||
final param = {
|
||||
'cid': cid,
|
||||
'content': content,
|
||||
'level': level,
|
||||
'objID': objID,
|
||||
"objType": objType,
|
||||
"quoteID": quoteID,
|
||||
"quoteImg": quoteImg,
|
||||
"quoteTitle": quoteTitle,
|
||||
"quoteType": quoteType,
|
||||
'rid': rid,
|
||||
'toUserID': toUserID,
|
||||
'image': image,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/comment/send',
|
||||
param: param,
|
||||
);
|
||||
if (result.isSuccess && result.data is Map) {
|
||||
return ReplyModel.fromJson(result.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
///评论的全部回复列表
|
||||
static Future<ListBaseModel<ReplyModel>?> getReplyList(
|
||||
String? objID,
|
||||
String? cmtId,
|
||||
String? curTime,
|
||||
int? pageNumber,
|
||||
int? pageSize, [
|
||||
String? fstID,
|
||||
]) async {
|
||||
final param = <String, dynamic>{
|
||||
'objID': objID,
|
||||
'cmtId': cmtId,
|
||||
'curTime': curTime,
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'fstID': fstID
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/comment/info',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<ReplyModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//公共服务api接口
|
||||
import 'package:hgdj/hj_model/home/video_list_model.dart';
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../alert/vip_guide/guide_manager.dart';
|
||||
import '../../alert/vip_guide/guide_push_model.dart';
|
||||
import '../../hj_model/banner/comment_top_banner_model.dart';
|
||||
import '../../hj_model/home/plate_model.dart';
|
||||
import '../../hj_model/home/update_marker_model.dart';
|
||||
import '../../hj_model/mine/follow_user_list_model.dart';
|
||||
import '../../hj_model/mine/happy/happy_model.dart';
|
||||
import '../../hj_model/mine/task_center_data.dart';
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../tools_base/event_bus/event_bus_util.dart';
|
||||
|
||||
class CommonService {
|
||||
/// 获取远端配置
|
||||
static Future<DomainSourceModel?> fetchRemoteConfig() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ping/domain',
|
||||
jsonTransformation: (json) => DomainSourceModel.fromJson(json),
|
||||
);
|
||||
// 请求/解析失败时 result.data 为 null,返回可空类型,
|
||||
// 避免把 null 强转成非空 DomainSourceModel 触发 type 'Null' is not a subtype 崩溃
|
||||
return result.isSuccess ? result.data as DomainSourceModel? : null;
|
||||
}
|
||||
|
||||
/// 刷新用户分层弹窗配置(paymentStatusPopup / paymentGuide),[keys] 指定刷新分块,以后新增本地自己加
|
||||
/// 返回 paymentPopup 分块(status / cardId / config 图片配置),写回 Config 由调用方处理;
|
||||
/// paymentGuide 分块直接写进 [GuideManager],不经调用方
|
||||
static Future<PayTierModel?> refreshPayPopup({List<String>? keys}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ping/domain/refresh',
|
||||
param: {'keys': keys},
|
||||
jsonTransformation: (json) {
|
||||
GuideManager().update(json['paymentGuide']); //付费引导开关(登录/退登/开通会员后分层会变)
|
||||
// 容错:后端未返回有效 paymentPopup(null/非对象/空对象)时返回 null,调用方据此不替换本地状态
|
||||
final popup = json['paymentPopup'];
|
||||
if (popup is! Map || popup.isEmpty) return null;
|
||||
return PayTierModel.fromJson(json['paymentPopup']);
|
||||
},
|
||||
);
|
||||
return result.isSuccess ? result.data as PayTierModel? : null;
|
||||
}
|
||||
|
||||
/// 首页内容更新红点标记
|
||||
static Future<HomeUpdateMarkersResp?> fetchUpdateMarkers() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/content/update-markers',
|
||||
jsonTransformation: (json) => HomeUpdateMarkersResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 活动结束或轮询时获取最新 banner 数据
|
||||
static Future<BannerJumpEntity?> pingBanner(String id) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ping/banner/$id',
|
||||
jsonTransformation: (json) => BannerJumpEntity.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data as BannerJumpEntity? : null;
|
||||
}
|
||||
|
||||
/// 按场景拉取 Banner 列表(如评论置顶 COMMENT_TOP)
|
||||
static Future<List<CommentTopBannerModel>> fetchBannerList(
|
||||
{required String scene}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/banner/list',
|
||||
param: {'scene': scene},
|
||||
jsonTransformation: (json) {
|
||||
final list = json['list'] as List?;
|
||||
if (list == null) return <CommentTopBannerModel>[];
|
||||
final items = list
|
||||
.map((e) => CommentTopBannerModel.fromJson(
|
||||
e is Map ? Map<String, dynamic>.from(e) : null))
|
||||
.toList();
|
||||
items.sort((a, b) => (b.sort ?? 0).compareTo(a.sort ?? 0));
|
||||
return items;
|
||||
},
|
||||
);
|
||||
//失败时 data 是没经过 jsonTransformation 的原始结构,硬 as 会抛,直接给空列表
|
||||
if (!result.isSuccess) return [];
|
||||
return result.data as List<CommentTopBannerModel>? ?? [];
|
||||
}
|
||||
|
||||
//获取模块数据
|
||||
static Future<HomePlateModel?> getTagMarks() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/modules/list',
|
||||
param: {},
|
||||
jsonTransformation: (json) => HomePlateModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//发起点赞
|
||||
//SP:长视频 SHORT:短视频 COVER:图文帖子 PIC:图集帖子 SEED_LINK:种子/黄油帖子 TAG:标签 COMMENT:评论 video:动漫 image:漫画 text:小说
|
||||
static Future<bool> sendLike(String? objID, String? type) async {
|
||||
final param = {'objID': objID, 'type': type};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/thumbsUp',
|
||||
param: param,
|
||||
);
|
||||
if (result.isSuccess) {
|
||||
eventBus.emit(CollectStatusModel(
|
||||
id: objID, isLiked: true, type: type, likeCountDelta: 1));
|
||||
}
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//取消点赞
|
||||
//SP:长视频 SHORT:短视频 COVER:图文帖子 PIC:图集帖子 SEED_LINK:种子/黄油帖子 TAG:标签 COMMENT:评论 video:动漫 image:漫画 text:小说
|
||||
static Future<bool> cancelLike(String? objID, String? type,
|
||||
{List<String>? objIDArr}) async {
|
||||
assert((type != null && type.isNotEmpty), 'cancelLike type 不能为空');
|
||||
List<String> ids = [];
|
||||
if (objIDArr?.isNotEmpty == true) {
|
||||
ids.addAll(objIDArr!);
|
||||
} else {
|
||||
ids.add(objID ?? "");
|
||||
}
|
||||
final param = {'objIDs': ids, 'type': type};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/thumbsDown',
|
||||
param: param,
|
||||
);
|
||||
if (result.isSuccess) {
|
||||
eventBus.emit(CollectStatusModel(
|
||||
id: objID, isLiked: false, type: type, likeCountDelta: -1));
|
||||
}
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// 获取我关注用户发的视频
|
||||
/// type 为0 获取用户所有动态, 1为获取用户全部视频
|
||||
static Future<VideoListResp?> fetchFollowDynamics(
|
||||
{int page = 1,
|
||||
int size = 20,
|
||||
int? uid,
|
||||
int? type,
|
||||
String? newsType}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageSize': size,
|
||||
'pageNumber': page,
|
||||
'uid': uid,
|
||||
'type': type,
|
||||
'newsType': newsType
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/follow/dynamics/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取关注列表
|
||||
static Future<ListBaseModel<FollowUserModel>?> getFollowUsers({
|
||||
required int pageNumber,
|
||||
required int pageSize,
|
||||
int? uid,
|
||||
int? followUserType,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'uid': uid,
|
||||
'type': followUserType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/follow/list',
|
||||
param: param, jsonTransformation: (json) {
|
||||
return ListBaseModel<FollowUserModel>.fromJson(json);
|
||||
});
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取金主广告列表
|
||||
static Future<HappyModel?> happyList() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/recreation/list',
|
||||
param: {},
|
||||
jsonTransformation: (json) => HappyModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<TaskCenterData?> getTaskList() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/task/list',
|
||||
param: {},
|
||||
jsonTransformation: (json) => TaskCenterData.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<bool> doTask(String? taskId, int? type) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/task/do',
|
||||
param: {'taskId': taskId, 'type': type},
|
||||
jsonTransformation: (json) => TaskCenterData.fromJson(json),
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//推荐视频列表
|
||||
static Future<VideoListResp?> getRecommendList({
|
||||
required int pageNumber,
|
||||
required int pageSize,
|
||||
String? tagId,
|
||||
String? newsType,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'tagId': tagId,
|
||||
'newsType': newsType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/vid/recommend/list',
|
||||
param: param, jsonTransformation: (json) {
|
||||
return VideoListResp.fromJson(json);
|
||||
});
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//广告点击统计 广告类型 0:应用 1:广告
|
||||
static Future<bool> adsClick(int? type) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/ads/click/stat',
|
||||
param: {'type': type},
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// 获取付费引导弹窗配置
|
||||
/// [scene] 场景,VIP 内容上新固定传 VIP_CONTENT_UPDATE
|
||||
static Future<GuidePushModel?> fetchPaymentGuide(String scene) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/payment/guide',
|
||||
param: {'scene': scene},
|
||||
jsonTransformation: (json) => GuidePushModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 上报付费引导弹窗已展示(只在弹窗真的展示出来后调,目前只有 VIP 内容上新横幅会上报)
|
||||
/// [requestId] 客户端每次展示生成的 UUID,接口按它幂等——失败重试要用同一个
|
||||
static Future<bool> reportPaymentGuideImpression({
|
||||
String? configId,
|
||||
required String scene,
|
||||
String? contentVersion,
|
||||
required String requestId,
|
||||
}) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/payment/guide/impression',
|
||||
param: {
|
||||
'configId': configId,
|
||||
'scene': scene,
|
||||
'contentVersion': contentVersion ?? '',
|
||||
'requestId': requestId,
|
||||
},
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
/// 短剧接口。金币解锁已并入 PayManager/BuyService,这里只剩读操作 + 两个计数型写操作:
|
||||
/// 信息流翻页去重、分享互动计数,都必须带 `X-Request-ID`——同一次业务的重试要复用同一个 id,
|
||||
/// 换新 id 就会重复计数
|
||||
class DramaService {
|
||||
static Options _idempotent(String requestId) =>
|
||||
Options(headers: {'X-Request-ID': requestId});
|
||||
|
||||
/// AI短剧信息流:一条 = 一部剧 + 该剧第 1 集。
|
||||
/// 排序、去重、已看沉底全在服务端,客户端不得二次排序,也不分页页码——每次请求都是下一批
|
||||
static Future<ListBaseModel<DramaFeedItem>?> fetchFeed(
|
||||
{int size = 20, String? requestId}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/drama/feed',
|
||||
param: {'pageSize': size},
|
||||
options: requestId == null ? null : _idempotent(requestId),
|
||||
jsonTransformation: (json) => ListBaseModel<DramaFeedItem>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 短剧专题列表(热门短剧 Tab,按 sort 倒序已由服务端排好)
|
||||
static Future<List<DramaTopic>?> fetchTopics() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/drama/topics',
|
||||
jsonTransformation: (json) =>
|
||||
(json['list'] as List?)
|
||||
?.map((e) => DramaTopic.fromJson(e))
|
||||
.toList() ??
|
||||
<DramaTopic>[],
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 专题作品列表(热门短剧橱窗)
|
||||
static Future<ListBaseModel<DramaMediaInfo>?> fetchTopicWorks(
|
||||
String topicId, {
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
}) async {
|
||||
if (topicId.isEmpty) return null;
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/drama/topic/works',
|
||||
param: {
|
||||
'topicId': topicId,
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
},
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<DramaMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 搜索短剧:`GET /media/search`,[kind] 固定 4
|
||||
/// [sortType] 1 最新上架、2 最多观看;默认 1
|
||||
static Future<DramaSearchResult?> search({
|
||||
required String keyword,
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
int sortType = 1,
|
||||
}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/search',
|
||||
param: {
|
||||
'keyword': keyword,
|
||||
'kind': 4,
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'sortType': sortType,
|
||||
},
|
||||
jsonTransformation: (json) => DramaSearchResult.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 短剧详情(剧信息 + resume 续播位置,不含分集)
|
||||
static Future<DramaMediaInfo?> fetchDetail(String? dramaId) async {
|
||||
if (dramaId?.isNotEmpty != true) return null;
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/info',
|
||||
param: {'id': dramaId},
|
||||
jsonTransformation: (json) => DramaMediaInfo.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 短剧片库搜索:标签页用它按标签取剧,走的是和 ACG 片库同一个接口。
|
||||
/// [tagIds] 空/不传 = 不按标签筛;[sortType] 1热门推荐 2最新上架 4最多收藏
|
||||
static Future<ListBaseModel<DramaMediaInfo>?> searchLibrary({
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
List<String>? tagIds,
|
||||
int? sortType,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'mediaType': 'drama',
|
||||
'sortType': sortType,
|
||||
'tagIds': tagIds,
|
||||
}..removeWhere((k, v) => v == null || (v is List && v.isEmpty));
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/media/library/search',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<DramaMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 分集列表(选集面板用) [sortType] 0-正序 1-倒序
|
||||
static Future<ListBaseModel<MediaContent>?> fetchEpisodeList(
|
||||
String dramaId, int page, int size) async {
|
||||
if (dramaId.isEmpty) return null; // 与 fetchDetail 一致:没有剧 id 就别发空请求
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_content/list',
|
||||
param: {
|
||||
'mediaId': dramaId,
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'sortType': 0
|
||||
},
|
||||
jsonTransformation: (json) => ListBaseModel<MediaContent>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 分集详情:播放地址 + canPlay + 未解锁时的 paywall。
|
||||
/// 列表里的播放地址会过期,每次起播都得重新拉这个接口,不能跨时长缓存
|
||||
static Future<MediaContent?> fetchEpisode(String? contentId) async {
|
||||
if (contentId?.isNotEmpty != true) return null;
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_content/info',
|
||||
param: {'id': contentId},
|
||||
jsonTransformation: (json) => MediaContent.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 我的喜欢-短剧
|
||||
static Future<ListBaseModel<DramaMediaInfo>?> fetchLikes(int page,
|
||||
{int size = 20}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/like',
|
||||
param: {'likeType': 'drama', 'pageNumber': page, 'pageSize': size},
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<DramaMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 我的收藏-短剧 [sortType] 0-最新收藏 1-最近更新
|
||||
static Future<ListBaseModel<DramaMediaInfo>?> fetchFavorites(int page,
|
||||
{int size = 20, int sortType = 0}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media_bookshelf/list',
|
||||
param: {
|
||||
'type': 'drama',
|
||||
'sortType': sortType,
|
||||
'pageNumber': page,
|
||||
'pageSize': size
|
||||
},
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<DramaMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 我的已购-短剧(按剧去重,点进去看每集的 hasBuy)
|
||||
static Future<ListBaseModel<DramaMediaInfo>?> fetchPurchased(int page,
|
||||
{int size = 20}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/media/my_buy',
|
||||
param: {'mediaType': 'drama', 'pageNumber': page, 'pageSize': size},
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<DramaMediaInfo>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 短剧单集下载授权:**下载链路唯一入口**。
|
||||
/// 登录态、上下架、短剧权益、钱包下载次数校验 + 扣 1 次 + 幂等,服务端一次做完并回下载地址,
|
||||
/// 前端不要再调 `/mine/privilege/consume` 或 `/mine/download/use`——那两个只扣次不给资源,会重复扣。
|
||||
/// [requestId] 同一次下载(含超时重试、刷新过期地址)必须复用,换新的会再扣一次;
|
||||
/// 返回整个 [BaseRespBean]:失败要按 code 分流引导,不能只看有没有数据
|
||||
static Future<BaseRespBean> authorizeDownload({
|
||||
required String mediaId,
|
||||
required String contentId,
|
||||
required String requestId,
|
||||
}) {
|
||||
return httpManager.fetchResponseByPOST(
|
||||
'/media/drama/download/authorize',
|
||||
param: {'mediaId': mediaId, 'contentId': contentId},
|
||||
options: _idempotent(requestId),
|
||||
jsonTransformation: (json) => DramaDownloadAuth.fromJson(json),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分享落地:服务端按 [eventId] 幂等累计分享互动数,返回二维码 PNG 的 base64
|
||||
static Future<String?> shareOutput({
|
||||
required String content,
|
||||
required String? mediaId,
|
||||
String? contentId,
|
||||
required String eventId,
|
||||
}) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/share/output',
|
||||
param: {
|
||||
'content': content,
|
||||
'objType': 'drama',
|
||||
'mediaID': mediaId,
|
||||
'contentID': contentId ?? '',
|
||||
'eventId': eventId,
|
||||
},
|
||||
options: _idempotent(eventId),
|
||||
);
|
||||
return result.isSuccess
|
||||
? (result.data is Map ? result.data['qrCode'] : null)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../hj_page/community/group_chat/model/im_group_list_model.dart';
|
||||
import '../../hj_page/community/group_chat/model/im_message_resp.dart';
|
||||
|
||||
class GroupService {
|
||||
///获取im群组列表
|
||||
static Future<IMGroupListModel?> getList(int pageNumber,
|
||||
{int pageSize = 10}) async {
|
||||
final result = await httpManager.fetchResponseByGET('/imgroup/list',
|
||||
param: {'pageNumber': pageNumber, 'pageSize': pageSize},
|
||||
jsonTransformation: (json) => IMGroupListModel.fromJson(json));
|
||||
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///获取是否加入群组详情
|
||||
static Future<BaseRespBean> getHasJoin(int? groupId) async {
|
||||
return httpManager
|
||||
.fetchResponseByGET('/imgroup/hasjoin', param: {'groupId': groupId});
|
||||
}
|
||||
|
||||
///获取im消息列表
|
||||
static Future<IMMessageResp> getMessages(
|
||||
int? groupId, int pageNumber, int pageSize) async {
|
||||
final result = await httpManager.fetchResponseByGET('/immessage/list',
|
||||
param: {
|
||||
'groupId': groupId,
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize
|
||||
},
|
||||
jsonTransformation: (json) => IMMessageResp.fromJson(json));
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
///发送im消息
|
||||
static Future<BaseRespBean> sendMessage(
|
||||
int? groupId, String? content, String? image) async {
|
||||
final data = {'groupId': groupId, 'content': content, 'image': image};
|
||||
data.removeWhere((k, v) => v == null);
|
||||
return httpManager.fetchResponseByPOST('/immessage/send', param: data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_model/mine/exchange_record_model.dart';
|
||||
import 'package:hgdj/hj_model/user/user_income_info_model.dart';
|
||||
import 'package:hgdj/hj_model/user/user_info_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_model/message/message_dynamic_list.dart';
|
||||
import '../../hj_model/mine/credit_record_model.dart';
|
||||
import '../../hj_model/mine/exchange/bill_item_model.dart';
|
||||
import '../../hj_model/mine/exchange/recharge_list_model.dart';
|
||||
import '../../hj_model/mine/exchange/recharge_url_model.dart';
|
||||
import '../../hj_model/mine/official_list_item_model.dart';
|
||||
import '../../hj_model/mine/promotion_record.dart';
|
||||
import '../../hj_model/mine/task_center_data.dart';
|
||||
import '../../hj_model/mine/vip_card_analytics_event.dart';
|
||||
import '../../hj_model/user/wallet_model.dart';
|
||||
import '../../hj_page/mine/make_money/in_come_entity.dart';
|
||||
import '../../hj_page/mine/make_money/withdraw_details_model.dart';
|
||||
import '../../hj_page/mine/message/un_read_msg_num_model.dart';
|
||||
import '../../hj_page/mine/mine_profit/model/alipay_bank_list_model.dart';
|
||||
import '../../hj_page/mine/mine_vip/coupon_model.dart';
|
||||
import '../../hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import '../../hj_page/mine/mine_vip/vip_support_model.dart';
|
||||
import '../../hj_page/mine/welfare/widget/checkin_model.dart';
|
||||
import '../../hj_page/mine/welfare/widget/sign_in_model.dart';
|
||||
|
||||
class MineService {
|
||||
/// 设备和二维码登陆
|
||||
/// [devID] 设备id
|
||||
/// [qrCnt] qr二维码
|
||||
/// [devType] 设备信息
|
||||
/// [sysType] ios/android
|
||||
/// [ver] version
|
||||
/// [buildID] packageName
|
||||
/// [devToken] 登录设备id验签
|
||||
/// [cutInfos] 粘贴板信息
|
||||
|
||||
static Future<UserInfoModel?> devLogin(
|
||||
devID,
|
||||
qrCnt,
|
||||
devType,
|
||||
sysType,
|
||||
ver,
|
||||
buildID,
|
||||
devToken, [
|
||||
cutInfos = "",
|
||||
]) async {
|
||||
ArgumentError.checkNotNull(devID, 'devID');
|
||||
ArgumentError.checkNotNull(qrCnt, 'qrCnt');
|
||||
ArgumentError.checkNotNull(devType, 'devType');
|
||||
ArgumentError.checkNotNull(sysType, 'sysType');
|
||||
ArgumentError.checkNotNull(ver, 'ver');
|
||||
ArgumentError.checkNotNull(buildID, 'buildID');
|
||||
ArgumentError.checkNotNull(devToken, 'devToken');
|
||||
final param = {
|
||||
'devID': devID,
|
||||
'qrCnt': qrCnt,
|
||||
'devType': devType,
|
||||
'sysType': sysType,
|
||||
'ver': ver,
|
||||
'buildID': buildID,
|
||||
'devToken': devToken,
|
||||
'cutInfos': cutInfos
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/login',
|
||||
param: param,
|
||||
jsonTransformation: (json) => UserInfoModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<bool> reduceDownloadCount() async {
|
||||
return (await httpManager.fetchResponseByPOST('/mine/download/use',
|
||||
param: <String, dynamic>{}))
|
||||
.isSuccess;
|
||||
}
|
||||
|
||||
/// 获取用户信息
|
||||
/// uid == 0 查询自己否则查询别人
|
||||
static Future<UserInfoModel?> getUserInfo([int? uid]) async {
|
||||
final param = {'uid': uid};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/info',
|
||||
param: param,
|
||||
jsonTransformation: (json) => UserInfoModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 提现明细
|
||||
static Future<WithdrawDetailsModel?> getWithdrawDetails(
|
||||
{int pageNumber = 1, int pageSize = 10}) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/withdraw/order',
|
||||
param: {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
},
|
||||
jsonTransformation: (json) => WithdrawDetailsModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 提现配置
|
||||
static Future<WithdrawConfig?> withdrawConfig() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/withdraw/cfg',
|
||||
jsonTransformation: (json) => WithdrawConfig.fromJson(json),
|
||||
);
|
||||
|
||||
//MinePublishDetailsModel
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 提现
|
||||
static Future<bool> withdraw(
|
||||
String? payType,
|
||||
String? act,
|
||||
int? money,
|
||||
String? name,
|
||||
String? actName,
|
||||
String? devID,
|
||||
String? bankCode,
|
||||
int? withdrawType,
|
||||
int? productType,
|
||||
) async {
|
||||
final param = {
|
||||
'payType': payType,
|
||||
'act': act,
|
||||
'money': money,
|
||||
'name': name,
|
||||
'actName': actName,
|
||||
'devID': devID,
|
||||
'bankCode': bankCode,
|
||||
'withdrawType': withdrawType,
|
||||
'productType': productType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/withdraw',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
static Future<bool> updateUserInfo(Map<String, dynamic>? map) async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/info',
|
||||
param: map ?? {},
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
static Future<UserInfoModel?> mobileLogin(
|
||||
String? mobile,
|
||||
String? code,
|
||||
String? devID,
|
||||
String? devType,
|
||||
String? sysType,
|
||||
String? ver,
|
||||
String? buildID,
|
||||
[String? cutInfos = ""]) async {
|
||||
final param = {
|
||||
'mobile': mobile,
|
||||
'code': code,
|
||||
'devID': devID,
|
||||
'devType': devType,
|
||||
'sysType': sysType,
|
||||
'ver': ver,
|
||||
'buildID': buildID,
|
||||
'cutInfos': cutInfos
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/mobileLoginOnly',
|
||||
param: param,
|
||||
jsonTransformation: (json) => UserInfoModel.fromJson(json['userInfo']));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取客服对接地址
|
||||
static Future<String?> fetchCustomService() async {
|
||||
final result =
|
||||
await httpManager.fetchResponseByGET('/im/whiteSign', param: {});
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 积分兑换商品
|
||||
static Future<bool> integralExchange(String id,
|
||||
{String? name, String? tel, String? address}) async {
|
||||
final param = <String, dynamic>{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"tel": tel,
|
||||
"address": address
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager
|
||||
.fetchResponseByPOST('/integral/exchangeIntegral', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//收藏类型 SP:长视频 SHORT:短视频 COVER:图文帖子 PIC:图集帖子 SEED_LINK:种子/黄油帖子
|
||||
static Future<bool> postCollect(
|
||||
String? objID, String? type, bool? isCollect) async {
|
||||
final param = {'objID': objID, 'type': type, 'isCollect': isCollect};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/collect',
|
||||
param: param,
|
||||
);
|
||||
eventBus.emit(
|
||||
CollectStatusModel(id: objID, isCollected: isCollect, type: type));
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
static Future<WalletModel?> fetchWalletData() async {
|
||||
final result = await httpManager.fetchResponseByGET('/mine/wallet',
|
||||
param: {}, jsonTransformation: (json) => WalletModel.fromJson(json));
|
||||
if (result.data is WalletModel) {
|
||||
globalStore.wallet = result.data;
|
||||
}
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//关注
|
||||
static Future<bool> getFollow(dynamic followUID, bool isFollow) async {
|
||||
final param = {'followUID': followUID, 'isFollow': isFollow};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/follow',
|
||||
param: param,
|
||||
);
|
||||
eventBus.emit(CollectStatusModel(uid: followUID, isFollowed: isFollow));
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//推广记录
|
||||
static Future<ListBaseModel<Promotion>?> getBindRecord(
|
||||
int? pageSize, int? pageNumber) async {
|
||||
final param = {'pageSize': pageSize, 'pageNumber': pageNumber};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/userinvite/userlist',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<Promotion>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//playTimeType: 0 默认全部 1 视频 2 帖子
|
||||
//type: hot 热度值排序;watch 最多播放;like 最多点赞(收藏);new 最新视频
|
||||
//status: 0 审核中 1 已通过 2未通过
|
||||
// newsType: SEED_LINK 游戏, MOVIE 视频,抖音:SP,图文:COVER
|
||||
|
||||
static Future<UserIncomeModel?> fetchIncomeInfo() async {
|
||||
final result = await httpManager.fetchResponseByPOST('/userinvite/info',
|
||||
param: {},
|
||||
jsonTransformation: (json) => UserIncomeModel.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取用户分享列表
|
||||
|
||||
static Future<InviteIncomeModel?> fetchIncomeList(
|
||||
{int page = 1, int size = 10}) async {
|
||||
final param = <String, dynamic>{'pageSize': page, 'pageNumber': size};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/userinvite/incomelist',
|
||||
param: param,
|
||||
jsonTransformation: (json) => InviteIncomeModel.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<ListBaseModel?> fetchLikes({
|
||||
int page = 1,
|
||||
int size = 10,
|
||||
String? likeType,
|
||||
int? uid,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'likeType': likeType,
|
||||
'uid': uid,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/like',
|
||||
param: param,
|
||||
jsonTransformation: (json) {
|
||||
if (likeType == 'image' || likeType == 'video' || likeType == 'text') {
|
||||
return ListBaseModel<CartoonMediaInfo>.fromJson(json);
|
||||
}
|
||||
return ListBaseModel<VideoModel>.fromJson(json);
|
||||
},
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取我买过电影
|
||||
/// [page] 页码 默认1
|
||||
/// [size] 页大小 默认20
|
||||
/// [newsType] 购买类型 SP视频,COVER:图集,POST:帖
|
||||
/// [uid] 查询自己不传
|
||||
static Future<ListBaseModel?> fetchBuyVideo({
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
int? uid,
|
||||
String? newsType,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'newsType': newsType,
|
||||
'uid': uid,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/mine/buyVid',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<VideoModel>.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
// 账单类型0_全部 1_金币 2_积分
|
||||
static Future<ListBaseModel<BillItemModel>?> getBillData({
|
||||
required int pageNumber,
|
||||
required int pageSize,
|
||||
int? type,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'type': type,
|
||||
'year': DateTime.now().year,
|
||||
'month': DateTime.now().month
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/mine/zhangdan',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<BillItemModel>.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
// 充值记录
|
||||
static Future<WithdrawDetailsModel?> getRechargeBill({
|
||||
required int pageNumber,
|
||||
required int pageSize,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByPOST('/mine/transaction',
|
||||
param: param,
|
||||
jsonTransformation: (json) => WithdrawDetailsModel.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
// 收益明细
|
||||
static Future<ListBaseModel<IncomeModel>?> getIncomeRecord({
|
||||
required int pageNumber,
|
||||
required int pageSize,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/iIncomes',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<IncomeModel>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//收藏的影视 video / img
|
||||
static Future<ListBaseModel<T>?> fetchCollectList<T>(
|
||||
String type, {
|
||||
int page = 1,
|
||||
int size = 20,
|
||||
int? uid,
|
||||
}) async {
|
||||
final param = {
|
||||
"type": type,
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'uid': uid ?? globalStore.meInfo?.uid,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/collect/infoList',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<T>.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///获取充值金额列表
|
||||
static Future<RechargeListModel?> getChatRechargeTypes(int? type) async {
|
||||
final param = {'type': type};
|
||||
final result = await httpManager.fetchResponseByGET('/mine/currencys',
|
||||
param: param,
|
||||
jsonTransformation: (json) => RechargeListModel.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 金币/VIP 充值下单
|
||||
///[finalPayStatus]: 预售业务,true 付尾款, false 付预定款;null 非预售业务;
|
||||
///[goldExtraID] 金币加赠券只有充值金币用到
|
||||
/// [rechargeType] 支付渠道
|
||||
/// [orderTrack] 下单来源/实验/会话等可选埋点字段(见 /mine/topay)
|
||||
static Future<RechargeUrlModel?> chargeGoldCoin(
|
||||
String? rechargeType, {
|
||||
String? productId,
|
||||
bool? isVip,
|
||||
String? goldExtraID,
|
||||
bool? finalPayStatus,
|
||||
PayOrderTrackInfo? orderTrack,
|
||||
// 兼容旧调用:仅 VIP 时回传实验信息;优先用 orderTrack
|
||||
String? experimentId,
|
||||
String? variant,
|
||||
}) async {
|
||||
ArgumentError.checkNotNull(rechargeType, 'rechargeType');
|
||||
final track = orderTrack;
|
||||
// DISABLED / 无实验时上游不填;空字符串也不传,避免参数错误
|
||||
final expId = track?.experimentId ?? experimentId;
|
||||
final expVariant = track?.experimentVariant ?? variant;
|
||||
final param = <String, dynamic>{
|
||||
'rechargeType': rechargeType,
|
||||
'productID': productId,
|
||||
'buyType': isVip == true ? 4 : 1,
|
||||
'cId': goldExtraID?.isNotEmpty == true ? goldExtraID : null,
|
||||
'uid': globalStore.meInfo?.uid,
|
||||
'goldExtraID': goldExtraID,
|
||||
'finalPayStatus': finalPayStatus,
|
||||
// 下单来源埋点(非必填)
|
||||
'sourcePage': track?.sourcePage?.value,
|
||||
'sourceRef': track?.sourceRef,
|
||||
'videoId': track?.videoId,
|
||||
'activityId': track?.activityId,
|
||||
'sessionId': track?.sessionId,
|
||||
// 短剧付费墙来源:服务端用这三个字段算「当前剧充值金币 / 当前剧开通会员卡」的收入归因
|
||||
'mediaId': track?.mediaId,
|
||||
'contentId': track?.contentId,
|
||||
'checkoutContextId': track?.checkoutContextId,
|
||||
'experimentId': (expId != null && expId.isNotEmpty) ? expId : null,
|
||||
'experimentVariant':
|
||||
(expVariant != null && expVariant.isNotEmpty) ? expVariant : null,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/topay',
|
||||
param: param,
|
||||
jsonTransformation: (json) => RechargeUrlModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///意见反馈
|
||||
static Future<bool> feedback(
|
||||
String content, {
|
||||
String? location,
|
||||
String? device,
|
||||
String? carrier,
|
||||
String? contact,
|
||||
List<String>? img,
|
||||
String? fType,
|
||||
}) async {
|
||||
final param = {
|
||||
'desc': content,
|
||||
"region": location,
|
||||
"devInfo": device,
|
||||
"ISP": carrier,
|
||||
'images': img,
|
||||
'cateInfo': fType,
|
||||
'contact': contact
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/feedback',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//积分兑换记录
|
||||
static Future<List<CreditRecordModel>> getCreditRecords(
|
||||
{int pageNumber = 1, int pageSize = 20}) async {
|
||||
final param = {'pageNumber': pageNumber, 'pageSize': pageSize};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/integral/record/list',
|
||||
param: param,
|
||||
);
|
||||
final List<CreditRecordModel> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(
|
||||
values?.map((e) => CreditRecordModel.fromJson(e)).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
///获取所有会员卡信息列表
|
||||
static Future<VipSupportModel?> getVipProduct({int? status}) async {
|
||||
final param = <String, dynamic>{'status': status};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vip/product',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VipSupportModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// VIP 卡片统计事件批量上报
|
||||
static Future<bool> reportAnalyticsEvents(
|
||||
List<VipCardAnalyticsEvent> events) async {
|
||||
if (events.isEmpty) return true;
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/analytics/events',
|
||||
param: <String, dynamic>{
|
||||
'events': events.map((e) => e.toJson()).toList(),
|
||||
},
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//积分兑换列表
|
||||
static Future<List<IntegralExchangeModel>> fetchExchangeList() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/integral/list',
|
||||
param: {},
|
||||
);
|
||||
final List<IntegralExchangeModel> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(
|
||||
values?.map((e) => IntegralExchangeModel.fromJson(e)).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
//获取官方社群
|
||||
static Future<List<OfficialListItemModel>?> getOfficialLists() async {
|
||||
final param = {
|
||||
'type': 2,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/official/list',
|
||||
param: param,
|
||||
);
|
||||
final List<OfficialListItemModel> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(
|
||||
values?.map((e) => OfficialListItemModel.fromJson(e)).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
//领取积分
|
||||
static Future<bool> receiveIntegral(String? taskId, int? type) async {
|
||||
final param = {'taskId': taskId, 'type': type};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/task/receive',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//兑换码兑换
|
||||
static Future<bool> postExchangeCode(String code) async {
|
||||
final param = {'code': code};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/code/exchange',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//绑定邀请码
|
||||
static Future<bool> getProxyBind(String promotionCode) async {
|
||||
final param = {'promotionCode': promotionCode};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/proxy/bind',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//发送验证码 type, 1绑定手机号,2 找回账号;
|
||||
static Future<bool> postCaptchaCode(String phone, int type) async {
|
||||
final param = {
|
||||
'type': type,
|
||||
'mobile': "+86$phone",
|
||||
};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/notification/captcha',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//绑定手机
|
||||
static Future<bool> bindPhone(String phone, String code,
|
||||
{String? devID,
|
||||
String? smsId,
|
||||
String? sysType,
|
||||
String? ver,
|
||||
String? devType,
|
||||
String? applicationID}) async {
|
||||
final param = {
|
||||
'mobile': "+86$phone",
|
||||
'code': code,
|
||||
'devID': devID,
|
||||
'smsId': smsId,
|
||||
'sysType': sysType,
|
||||
'ver': ver,
|
||||
'devType': devType,
|
||||
'applicationID': applicationID
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/mobileBind',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//动态、收益列表
|
||||
static Future<ListBaseModel<MessageDynamicList>?> getDynamicList(
|
||||
{int pageNumber = 1, int pageSize = 20, int? msgType}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'msgType': msgType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/msg/dynamic/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
ListBaseModel<MessageDynamicList>.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//未读消息列表
|
||||
static Future<UnReadMsgNumModel?> getUnreadNum() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/msg/dynamic/noRedNum',
|
||||
param: {},
|
||||
jsonTransformation: (json) => UnReadMsgNumModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 黑料社获取用户优惠券
|
||||
static Future<List<CouponModel>> fetchUserCoupons(int type,
|
||||
{int page = 1, int size = 0}) async {
|
||||
final param = <String, dynamic>{
|
||||
'type': type,
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/coupon/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) =>
|
||||
(json['list'] as List?)
|
||||
?.map((e) => CouponModel.fromJson(e))
|
||||
.toList() ??
|
||||
[]);
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
static Future<bool> bankCardDelete({String? id}) async {
|
||||
final param = <String, dynamic>{'id': id};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByDELETE(
|
||||
'/mine/txnact/del',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
static Future<ApBankListModel?> getBankCards() async {
|
||||
final result = await httpManager.fetchResponseByGET('/mine/txnact/yh/get',
|
||||
param: {},
|
||||
jsonTransformation: (json) => ApBankListModel.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<bool> addBankCard(
|
||||
String? act,
|
||||
String? actName,
|
||||
String? bankCode,
|
||||
String? cardType,
|
||||
) async {
|
||||
final param = <String, dynamic>{
|
||||
'act': act,
|
||||
'actName': actName,
|
||||
'bankCode': bankCode,
|
||||
'cardType': cardType
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/txnact/yh/add',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//获取兑换码记录
|
||||
static Future<ExchangeRecordList?> getExchangeRecord(
|
||||
int pageNumber, int pageSize) async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/code/userRecord',
|
||||
param: {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
},
|
||||
jsonTransformation: (json) => ExchangeRecordList.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取消息小红点
|
||||
static Future<NewMessageTip?> checkMessageTip() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/ping/checkMessageTip',
|
||||
param: {},
|
||||
jsonTransformation: (json) => NewMessageTip.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//获取消息小红点
|
||||
static Future<String?> certificateQR() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/certificate/qr',
|
||||
param: {},
|
||||
jsonTransformation: (json) {
|
||||
return json['content'] ?? '';
|
||||
},
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<List<String>> getPortrait() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/mine/portrait',
|
||||
);
|
||||
final List<String> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data['data'] as List?;
|
||||
list.addAll(values?.map((e) => e as String).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
//绑定邀请码
|
||||
static Future<bool> exchangeInviteCode(String code) async {
|
||||
final param = {'promotionCode': code};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/mine/inviteBind',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
//type: hot 热度值排序;watch 最多播放;like 最多点赞(收藏);new 最新视频
|
||||
//status: 0 审核中 1 已通过 2未通过
|
||||
|
||||
static Future<ListBaseModel<VideoModel>> fetchPublishes({
|
||||
int page = 1,
|
||||
int size = 10,
|
||||
int status = 0,
|
||||
String? newsType,
|
||||
int? uid,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'status': status,
|
||||
'uid': uid,
|
||||
'newsType': newsType
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/mine/publish',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<VideoModel>.fromJson(json));
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
static Future<String> deletePublishes({
|
||||
List<String>? ids,
|
||||
}) async {
|
||||
final param = <String, dynamic>{'ids': ids};
|
||||
final result = await httpManager.fetchResponseByPOST('/mine/publish/delete',
|
||||
param: param);
|
||||
if (result.isSuccess) {
|
||||
return '';
|
||||
}
|
||||
return result.tip ?? result.msg ?? '';
|
||||
}
|
||||
|
||||
//获取优惠券
|
||||
static Future<List<AICouponModel>?> backPack(
|
||||
int page, {
|
||||
int? limit = 10, //每页条数
|
||||
int? status = 2, //物品状态 1-已使用 2-未使用 3-过期
|
||||
int? type = 3, //1-楼风解锁折扣卷 2-会员折扣卷 3-AI换脸折扣券
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'status': status,
|
||||
'type': type,
|
||||
}..removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/backpack',
|
||||
param: param,
|
||||
jsonTransformation: (json) => CouponModel.fromJson(json),
|
||||
);
|
||||
final List<AICouponModel> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(values?.map((e) => AICouponModel.fromJson(e)).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// 签到
|
||||
static Future<CheckinDoResp?> postDayMark() async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/checkin/click',
|
||||
param: {},
|
||||
);
|
||||
if (!result.isSuccess || result.data == null) return null;
|
||||
return CheckinDoResp.fromJson(result.data);
|
||||
}
|
||||
|
||||
// 会员补领签到奖励
|
||||
static Future<CheckinDoResp?> claimCheckinVip() async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/checkin/claim_vip',
|
||||
param: {},
|
||||
);
|
||||
if (!result.isSuccess || result.data == null) return null;
|
||||
return CheckinDoResp.fromJson(result.data!);
|
||||
}
|
||||
|
||||
//签到额外奖励
|
||||
static Future<List<ExtraSignRewardItem>?> getExtraDayMark() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/task/sign-extra-prizes',
|
||||
param: {},
|
||||
);
|
||||
|
||||
final List<ExtraSignRewardItem> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(
|
||||
values?.map((e) => ExtraSignRewardItem.fromJson(e)).toList() ?? []);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
//获取签到列表
|
||||
static Future<CheckinPrizeResp?> getSignList() async {
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/checkin/prize',
|
||||
param: {},
|
||||
);
|
||||
if (!result.isSuccess || result.data == null) return null;
|
||||
return CheckinPrizeResp.fromJson(result.data);
|
||||
}
|
||||
|
||||
// 补签
|
||||
static Future<bool> postReSign(String id) async {
|
||||
final param = {'id': id};
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/task/resign',
|
||||
param: param,
|
||||
);
|
||||
return result.isSuccess;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//搜索相关业务
|
||||
|
||||
import 'package:hgdj/hj_model/home/video_list_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
class SearchService {
|
||||
/// 获取交友发布tag
|
||||
static Future<List<TagsBean>> fetchPublishTags() async {
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/search/publisher/list',
|
||||
param: <String, dynamic>{},
|
||||
jsonTransformation: (json) =>
|
||||
(json['list'] as List?)?.map((e) => TagsBean.fromMap(e)).toList() ??
|
||||
[]);
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
//猜你想要 type: 0v本周, 1本月,2上月 3.最多收藏
|
||||
static Future<VideoListResp?> getHotVideos(
|
||||
int pageNumber, int pageSize, int sortType) async {
|
||||
final param = {
|
||||
"pageNumber": pageNumber,
|
||||
"pageSize": pageSize,
|
||||
"type": sortType,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/search/hotVid/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//realm: SP-视频 SHORT-短视频 DRAMA-短剧 COVER-帖子 USER-用户
|
||||
//sortType: 0综合排序 1最多观看 2最新上架 3最多收藏
|
||||
static Future<VideoListResp?> searchMedia(
|
||||
String keyWords, {
|
||||
int pageNumber = 1,
|
||||
int pageSize = 20,
|
||||
String realm = 'SP',
|
||||
int? sortType,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
'keyWords': [keyWords],
|
||||
'realm': realm,
|
||||
'sortType': sortType,
|
||||
}..removeWhere((k, v) => v == null);
|
||||
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/search/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:hgdj/hj_model/home/video_list_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
class TagService {
|
||||
static Future<TagsBean?> fetchInfo(String? tagId) async {
|
||||
final param = <String, dynamic>{'tagID': tagId};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/tag/info',
|
||||
param: param, jsonTransformation: (json) => TagsBean.fromMap(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 标签详情拉去视频数据
|
||||
///sortType 1、推荐,2、最新,3、最热 4、精华,5、视频
|
||||
static Future<VideoListResp?> fetchList(String? tagId,
|
||||
{int? sortType,
|
||||
int page = 1,
|
||||
int size = 10,
|
||||
String? newsType,
|
||||
String? sortBy,
|
||||
int? videoType}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'sortType': sortType,
|
||||
'tagID': tagId,
|
||||
'newsType': newsType,
|
||||
'sortBy': sortBy,
|
||||
'videoType': videoType
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/tag/vid/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import 'package:hgdj/hj_model/list_base_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/codec_support.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_model/home/module_detail_model.dart';
|
||||
import '../../hj_model/home/recommend_list_model.dart';
|
||||
import '../../hj_model/home/video_library_model.dart';
|
||||
import '../../hj_model/home/video_list_model.dart';
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../hj_model/splash/watch_count_model.dart';
|
||||
|
||||
class VidService {
|
||||
/// 根据id获取观看次数
|
||||
/// [vid] 视频vid 可null
|
||||
static Future<WatchCount?> fetchWatchCount({String? vid}) async {
|
||||
final param = <String, dynamic>{'vid': vid};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET('/vid/user/count',
|
||||
param: param, jsonTransformation: (json) => WatchCount.fromJson(json));
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 发送观看视频记录
|
||||
static Future<bool> sendRecord(
|
||||
String videoID, {
|
||||
int? playWay,
|
||||
int? longer,
|
||||
int? progress,
|
||||
int? via,
|
||||
String? tagID,
|
||||
}) async {
|
||||
final param = {
|
||||
'videoID': videoID,
|
||||
'playWay': playWay,
|
||||
'longer': longer,
|
||||
'progress': progress,
|
||||
'via': via,
|
||||
'tagID': tagID,
|
||||
};
|
||||
final result =
|
||||
await httpManager.fetchResponseByPOST('/vid/play', param: param);
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
/// 用户上传视频发帖接口
|
||||
/// [actor] 演员名字
|
||||
/// [bountyPoint] 求车牌悬赏积分
|
||||
/// [coins] 观看金币
|
||||
/// [content] 发帖内容
|
||||
/// [cover] 视频封面
|
||||
/// [coverThumb] 封面缩略图
|
||||
/// [filename] 上传的文件名 一般视频用
|
||||
/// [freeTime] 免费观看时长
|
||||
/// [isActivity] 是否为参赛作品
|
||||
/// [md5] 文件摘要上传视频需要
|
||||
/// [mimeType] 影片类型
|
||||
/// [newsType] 上传类型 SP:短视频,MOVIE:影视,COVER:图组,POST:帖子,CAR_NO:求车牌
|
||||
/// [playTime] 视频时长
|
||||
/// [ratio] 宽高比
|
||||
/// [relatedSid] 关联番号
|
||||
/// [resolution] 分辨率
|
||||
/// [seedLink] 磁力链接
|
||||
/// [seriesCover] 图集
|
||||
/// [size] 文件大小
|
||||
/// [sourceID] 上传视频成功后 返回的ID 为SP时必传
|
||||
/// [sourceURL] 资源url 为SP,MOVIE时 必传
|
||||
/// [tags] 视频标签数组
|
||||
/// [title] 发帖的标题
|
||||
/// [uid] 用户id
|
||||
static Future<bool> submit(
|
||||
{String? actor,
|
||||
int? bountyPoint,
|
||||
int? coins,
|
||||
String? content,
|
||||
String? cover,
|
||||
String? coverThumb,
|
||||
String? filename,
|
||||
int? freeTime,
|
||||
bool? isActivity,
|
||||
String? md5,
|
||||
String? mimeType,
|
||||
String? newsType,
|
||||
int? playTime,
|
||||
double? ratio,
|
||||
String? relatedSid,
|
||||
String? resolution,
|
||||
List<String>? seriesCover,
|
||||
int? size,
|
||||
String? sourceID,
|
||||
String? sourceURL,
|
||||
List<String>? tags,
|
||||
String? title,
|
||||
int? uid,
|
||||
String? via,
|
||||
String? seedLink}) async {
|
||||
final param = {
|
||||
'title': title,
|
||||
'newsType': newsType,
|
||||
'tags': tags,
|
||||
'playTime': playTime,
|
||||
'cover': cover,
|
||||
'coverThumb': coverThumb,
|
||||
'seriesCover': seriesCover,
|
||||
'via': via,
|
||||
'coins': coins,
|
||||
'size': size,
|
||||
'mimeType': mimeType,
|
||||
'actor': actor,
|
||||
'sourceURL': sourceURL,
|
||||
'sourceID': sourceID,
|
||||
'filename': filename,
|
||||
'resolution': resolution,
|
||||
'ratio': ratio,
|
||||
'md5': md5,
|
||||
'freeTime': freeTime,
|
||||
'isActivity': isActivity,
|
||||
'content': content,
|
||||
'seedLink': seedLink,
|
||||
'relatedSid': relatedSid,
|
||||
'bountyPoint': bountyPoint,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result =
|
||||
await httpManager.fetchResponseByPOST('/vid/submit', param: param);
|
||||
if (!result.isSuccess) {
|
||||
showToast(result.toast);
|
||||
}
|
||||
return result.isSuccess;
|
||||
}
|
||||
|
||||
///获取模块详情
|
||||
/// [moduleSort] 默认视频排序 1、最新,2、最热,3、最多播放量或者推荐,4、十分钟以上视频,5、精华,6、视频 9 最新热评
|
||||
/// [showType]0 默认为hj
|
||||
static Future<ModuleDetailModel?> getModuleDetail(String? subModuleID,
|
||||
{int? moduleSort,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10,
|
||||
int? showType,
|
||||
String? tagId}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageSize': pageSize,
|
||||
'pageNumber': pageNumber,
|
||||
'moduleSort': moduleSort,
|
||||
'showType': showType,
|
||||
'tagId': tagId,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/module/$subModuleID',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ModuleDetailModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 亚模块热门随机刷新(仅 refreshMode=RANDOM_TOP_N 的排序项)
|
||||
/// 只回 allVideoInfo,不含专题/精选等结构,调用方别整个替换 dataSource
|
||||
/// [refreshToken] 每次用户主动下拉换新 UUID;同一 token 重取顺序不变,所以网络层自动重试是安全的
|
||||
static Future<ModuleDetailModel?> refreshRandomModule(String? subModuleID,
|
||||
{int? moduleSort,
|
||||
required String refreshToken,
|
||||
int pageSize = 30}) async {
|
||||
final param = <String, dynamic>{
|
||||
'moduleSort': moduleSort,
|
||||
'pageSize': pageSize,
|
||||
'refreshToken': refreshToken,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/module/$subModuleID/refresh',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ModuleDetailModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
///sortType 1:最新 2:本周热门 4:本月最热 5:年度最热
|
||||
static Future<VideoListResp?> getNewestModule(
|
||||
{int? sortType, int pageNumber = 1, int pageSize = 10}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageSize': pageSize,
|
||||
'pageNumber': pageNumber,
|
||||
'sortType': sortType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/home/new/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<dynamic> getShopAddress() async {
|
||||
final result =
|
||||
await httpManager.fetchResponseByGET('/ping/store_url', param: {});
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
// 获取视屏详情 番号 番号即是视频id
|
||||
static Future<VideoModel?> getDetail(
|
||||
String videoID, {
|
||||
String? sectionId,
|
||||
String? postId,
|
||||
String? seedId,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
"videoID": videoID,
|
||||
"sectionId": sectionId,
|
||||
"id": postId,
|
||||
'seedId': seedId,
|
||||
'supportH265': CodecSupport.useH265, //设备是否用 265,后端据此决定是否下发 h265Url
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/info',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 短视频推荐列表:GET /recommend/vid/list
|
||||
/// 仅传 pageNumber、pageSize(固定20);是否还有下一页以 hasNext 为准,不上传已看视频 ID/offset。
|
||||
static Future<RecommendListRes?> getRecommendList(
|
||||
int? pageNumber, {
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
final param = {'pageNumber': pageNumber, 'pageSize': pageSize};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/recommend/vid/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => RecommendListRes.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<VideoListResp?> fetchSectionVideos(
|
||||
String sectionID, {
|
||||
String? sortType,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
"pageNumber": pageNumber,
|
||||
"pageSize": pageSize,
|
||||
"sortType": sortType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/section/$sectionID',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
static Future<SectionListResp?> fetchSectionAll(
|
||||
String mid, {
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
"pageNumber": pageNumber,
|
||||
"pageSize": pageSize,
|
||||
"mid": mid,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/section/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => SectionListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 片库搜索视频
|
||||
static Future<HomeVideoLibraryResult?> searchLibrary(
|
||||
int pageNumber,
|
||||
int pageSize, {
|
||||
Keyword? filterMenu,
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
'pageNumber': pageNumber,
|
||||
'pageSize': pageSize,
|
||||
};
|
||||
param.addAll({'keyword': filterMenu?.toJson() ?? {}});
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByPOST(
|
||||
'/vid/library/search',
|
||||
param: param,
|
||||
jsonTransformation: (json) => HomeVideoLibraryResult.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取福利任务下面的视频
|
||||
static Future<List<WareDiscountAreaData>?> fetchFreeSource() async {
|
||||
final result = await httpManager.fetchResponseByGET('/vid/discount/area',
|
||||
param: {},
|
||||
jsonTransformation: (json) => (json['List'] as List?)
|
||||
?.map((e) => WareDiscountAreaData.fromJson(e))
|
||||
.toList());
|
||||
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取免费福利视频数据
|
||||
/// [sortType] 0 = 福利任务下面 1 最新 2最热
|
||||
static Future<ListBaseModel<VideoModel>> fetchFreeSourceList(String id,
|
||||
{int page = 1, int size = 10, required int sortType}) async {
|
||||
final param = {
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'discountId': id,
|
||||
'sortType': sortType,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET('/vid/discount/list',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ListBaseModel<VideoModel>.fromJson(json));
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/// 获取跑马灯数据
|
||||
static Future<List<MarqueeModel>?> fetchAnnounce(int type) async {
|
||||
final param = <String, dynamic>{"type": type};
|
||||
final result = await httpManager.fetchResponseByGET('/mine/announce/list',
|
||||
param: param);
|
||||
|
||||
final List<MarqueeModel> list = [];
|
||||
if (result.isSuccess) {
|
||||
List? values = result.data as List?;
|
||||
list.addAll(values?.map((e) => MarqueeModel.fromMap(e)).toList() ?? []);
|
||||
}
|
||||
Config.marquees = list;
|
||||
return list;
|
||||
}
|
||||
|
||||
//社区热门推荐
|
||||
static Future<ModuleDetailModel?> communityRecommend(
|
||||
int pageNumber, {
|
||||
int? pageSize = 10,
|
||||
String? newsType, //PIC:套图站热门推荐 SEED_LINK:黄游热门推荐
|
||||
int? sortType = 1, //排序:最新:1 最多点赞:2 最多观看:3 最多收藏:7 购买次数:8 最新热评:9
|
||||
}) async {
|
||||
final param = <String, dynamic>{
|
||||
"pageNumber": pageNumber,
|
||||
"pageSize": pageSize,
|
||||
"newsType": newsType,
|
||||
"sortType": sortType,
|
||||
};
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/community/recommend',
|
||||
param: param,
|
||||
jsonTransformation: (json) => ModuleDetailModel.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
//0 最多观看 1 最新上架 3 最多收藏
|
||||
static Future<AllSection?> getGuessLike(
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
String sectionID,
|
||||
int? sortType,
|
||||
) async {
|
||||
final param = <String, dynamic>{
|
||||
"pageNumber": pageNumber,
|
||||
"pageSize": pageSize,
|
||||
"type": sortType,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/module/all/$sectionID',
|
||||
param: param,
|
||||
jsonTransformation: (json) => AllSection.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
|
||||
/// 获取片库筛选条件
|
||||
static Future<HomeVideoLibrary> fetchLibrary() async {
|
||||
final result = await httpManager.fetchResponseByGET('/vid/library',
|
||||
param: {},
|
||||
jsonTransformation: (json) => HomeVideoLibrary.fromJson(json));
|
||||
//返回类型非空,给不了 null:失败时这里抛,由调用方 try/catch 兜底(改可空要连调用方一起改)
|
||||
return result.data;
|
||||
}
|
||||
|
||||
///视频专题样式-更换一批
|
||||
static Future<VideoListResp> sectionVideoExchange(String? sectionID) async {
|
||||
final result = await httpManager
|
||||
.fetchResponseByGET('/vid/section/changeVideo/$sectionID', param: {});
|
||||
//失败时 data 是原始结构,别拿它当模型解析;fromJson 内部 json ??= {},传 null 就是空列表
|
||||
return VideoListResp.fromJson(result.isSuccess ? result.data : null);
|
||||
}
|
||||
|
||||
///发现-pk打榜数据
|
||||
///[newsType] SP-视频 COVER-帖子 PIC-图集;[type] 1-日榜 2-周榜 3-月榜 4-总榜
|
||||
static Future<VideoListResp?> vidRanking(
|
||||
int page,
|
||||
int size, {
|
||||
String? newsType,
|
||||
int? type,
|
||||
}) async {
|
||||
final param = {
|
||||
'pageNumber': page,
|
||||
'pageSize': size,
|
||||
'newsType': newsType,
|
||||
'type': type,
|
||||
};
|
||||
param.removeWhere((k, v) => v == null);
|
||||
final result = await httpManager.fetchResponseByGET(
|
||||
'/vid/ranking',
|
||||
param: param,
|
||||
jsonTransformation: (json) => VideoListResp.fromJson(json),
|
||||
);
|
||||
return result.isSuccess ? result.data : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import '../hj_model/video_model.dart';
|
||||
import '../tools_base/global_store/store.dart';
|
||||
import '../tools_base/net/net_manager.dart';
|
||||
import 'date_time_util.dart';
|
||||
|
||||
/// 是否需要开通 VIP 才能看
|
||||
bool needVip(VideoModel? model) {
|
||||
if (model?.freeArea == true) return false; // 免费区
|
||||
// VIP 未过期(比服务端校准时间,防改本地时间);日期为空/解析失败都当过期
|
||||
final expire = DateTime.tryParse(globalStore.meInfo?.vipExpireDate ?? '');
|
||||
if (expire?.isAfter(netManager.getFixedCurTime()) == true) return false;
|
||||
if (model?.isCoinVideo() == true) return false; // 金币视频走金币购买,与 VIP 无关
|
||||
if (globalStore.isMe(model?.publisher?.uid)) return false; // 自己发布的
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
会员权益:
|
||||
普通会员: VIP长视频 VIP抖音 社区帖子VIP 图集VIP 小说VIP ACGVIP
|
||||
高级会员:VIP+金币视频(长视频,抖音),社区VIP帖子。图集金币 小说 金币 ACG 。 暗网视频和社区金币帖子除外
|
||||
超级会员: 全网通 包含暗网+社区金币帖子
|
||||
黄游单独购买
|
||||
* */
|
||||
|
||||
/// 长视频 / 抖音是否需要花金币购买
|
||||
bool needCoin(VideoModel? model) {
|
||||
if (model == null) return false;
|
||||
final originCoins = model.originCoins ?? 0;
|
||||
if (originCoins == 0) return false; // 非金币视频
|
||||
if (globalStore.isMe(model.publisher?.uid)) return false; // 自己发布的
|
||||
if (model.freeArea == true) return false; // 免费区
|
||||
|
||||
final isVip = globalStore.isVIP;
|
||||
if (isVip && model.coins == 0) return false; // VIP 折后 0 金币
|
||||
if (globalStore.isSuperUp) return false; // 超级会员全网通
|
||||
// VIP 金币视频限免期内,50 金币以内免费
|
||||
if (isVip && DateTimeUtil.calTime3(globalStore.meInfo?.goldVideoFreeExpire) > 0 && originCoins <= 50) return false;
|
||||
return !(model.vidStatus?.hasPaid ?? false); // 已购则不用再买
|
||||
}
|
||||
|
||||
/// 社区帖子视频是否需要花金币购买(规则与长视频略有差异)
|
||||
bool needPostCoin(VideoModel? model) {
|
||||
if (model == null) return false;
|
||||
if (globalStore.isMe(model.publisher?.uid)) return false; // 自己发布的
|
||||
if (model.freeArea == true) return false; // 免费区
|
||||
if (globalStore.isSuperUp) return false; // 超级会员全网通
|
||||
|
||||
final isVip = globalStore.isVIP;
|
||||
if (isVip && (model.originCoins ?? 0) > 0 && model.coins == 0) return false; // VIP 折后 0 金币
|
||||
if (isVip && model.originCoins == 0) return false; // VIP 看非金币帖子
|
||||
return !(model.vidStatus?.hasPaid ?? false); // 已购则不用再买
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hgdj/hj_utils/store_keys.dart';
|
||||
import 'package:hgdj/track_event_manager/device_service.dart';
|
||||
|
||||
import 'light_model.dart';
|
||||
|
||||
/// H.265(HEVC) 能力判断 + 反应式回退。
|
||||
///
|
||||
/// 两层防线,最坏结果都是"回落 H.264",不会黑屏:
|
||||
/// 1. 主动:启动时查芯片**硬解**能力([_hwSupport]),只在支持的机器上选 265(省带宽);
|
||||
/// 软解一律当不支持(软解 1080p HEVC 中低端机必卡)。
|
||||
/// 2. 反应式:万一芯片虚报硬解 / iOS 的 hev1 封装 / 海思类问题导致 265 放不出,
|
||||
/// 由播放器 catch 里调 [disableForDevice] 永久置位并落本地,之后该机只播 264。
|
||||
/// 与 video_view_type 的 platformView 机制同一套路(不预判机型,出错再降级+持久化)。
|
||||
class CodecSupport {
|
||||
static const _channel = MethodChannel(DeviceInfoService.naticeChannel);
|
||||
|
||||
/// 芯片是否硬解 H.265(内存态,构造播放器时同步读)。hardware
|
||||
static bool _hwSupport = false;
|
||||
|
||||
/// 本机是否已被反应式禁用 265(持久化)。
|
||||
static bool _deviceDisabled = false;
|
||||
|
||||
/// 最终是否使用 265:芯片硬解支持 且 未被反应式禁用。
|
||||
static bool get useH265 => _hwSupport && !_deviceDisabled;
|
||||
|
||||
/// 芯片硬解能力 / 本机是否已禁 265(诊断用只读)
|
||||
static bool get hwSupport => _hwSupport;
|
||||
|
||||
static bool get deviceDisabled => _deviceDisabled;
|
||||
|
||||
/// 启动时调用一次(main 里):查硬解能力 + 读本机禁用标记。
|
||||
/// 任何异常都按"不支持"处理 → 回落 264 最安全。
|
||||
static Future<void> init() async {
|
||||
_deviceDisabled = await lightKV.getBool(StoreKeys.H265_DISABLED) ?? false;
|
||||
try {
|
||||
_hwSupport =
|
||||
await _channel.invokeMethod<bool>('isH265HardwareSupported') ?? false;
|
||||
} catch (e) {
|
||||
_hwSupport = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 播 265 失败且 platformView 也救不了时调用:本机永久回落 264 并落本地。
|
||||
/// 健康机(已禁用/从没启用过 265)都是空操作。返回 true 表示本次发生了禁用(值得回落重试)。
|
||||
static bool disableForDevice() {
|
||||
if (_deviceDisabled) return false;
|
||||
_deviceDisabled = true;
|
||||
lightKV.setBool(StoreKeys.H265_DISABLED, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
//媒体类型常量
|
||||
enum MediaStyle {
|
||||
Video, //真人视频
|
||||
ShortVideo, //短视频
|
||||
Pic, // 图集
|
||||
Game, // 黄游
|
||||
Novel, // 小说
|
||||
Cartoon, //动漫
|
||||
Comics, //漫画
|
||||
Drama, //短剧
|
||||
Community, //社区
|
||||
Area, //禁区
|
||||
Seed, //种子
|
||||
Actress, //女优
|
||||
NakedChat, //裸聊
|
||||
GroupChat, //群聊
|
||||
}
|
||||
|
||||
//搜索接口参数映射
|
||||
extension MediaStyleSearchParam on MediaStyle {
|
||||
//搜索 realm 参数
|
||||
String get searchRealm => switch (this) {
|
||||
MediaStyle.Video => 'SP', //影片
|
||||
MediaStyle.ShortVideo => 'SHORT', //抖音
|
||||
MediaStyle.Drama => 'DRAMA', //短剧
|
||||
MediaStyle.Community => 'COVER', //帖子
|
||||
MediaStyle.Pic => 'PIC', //图集
|
||||
_ => '',
|
||||
};
|
||||
|
||||
//ACG / 短剧搜索 kind:1动漫 2漫画 3小说 4短剧
|
||||
int? get searchKind => switch (this) {
|
||||
MediaStyle.Cartoon => 1,
|
||||
MediaStyle.Comics => 2,
|
||||
MediaStyle.Drama => 4,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
//排行榜参数映射
|
||||
extension MediaStyleRankParam on MediaStyle {
|
||||
//榜单 tab 标题
|
||||
String get rankTitle => switch (this) {
|
||||
MediaStyle.Video => '影片',
|
||||
MediaStyle.Cartoon => '动漫',
|
||||
MediaStyle.Comics => '漫画',
|
||||
MediaStyle.Community => '帖子',
|
||||
MediaStyle.Pic => '图集',
|
||||
_ => '',
|
||||
};
|
||||
|
||||
//榜单接口参数
|
||||
String get rankParam => switch (this) {
|
||||
MediaStyle.Video => 'SP',
|
||||
MediaStyle.Cartoon => 'video',
|
||||
MediaStyle.Comics => 'image',
|
||||
MediaStyle.Community => 'COVER',
|
||||
MediaStyle.Pic => 'PIC',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
//下载缓存 tab 标题
|
||||
extension MediaStyleCacheTitle on MediaStyle {
|
||||
String get cacheTabTitle => switch (this) {
|
||||
MediaStyle.ShortVideo => '抖音',
|
||||
MediaStyle.Cartoon => '动漫',
|
||||
MediaStyle.Drama => '短剧',
|
||||
_ => '影视',
|
||||
};
|
||||
}
|
||||
|
||||
//排序 tab 配置:标题 + 排序值(sort 类型随接口而定:int 或 String,具体配置放各业务 logic)
|
||||
class SortTab<T> {
|
||||
final String name;
|
||||
final T sort;
|
||||
const SortTab(this.name, this.sort);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:hgdj/tools_base/net/net_manager.dart';
|
||||
|
||||
class DateTimeUtil {
|
||||
/// 计算当前时间差值
|
||||
static int calTime3(String? date) {
|
||||
if (TextUtil.isEmpty(date) || date?.contains("0001-01-01") == true)
|
||||
return -1;
|
||||
final time = DateTime.parse(utc2iso(date)).toLocal();
|
||||
return time.difference(DateTime.now()).inSeconds;
|
||||
}
|
||||
|
||||
/// 日期时间本地格式化 yyyy-MM-dd HH:mm:ss
|
||||
static String utc2iso(String? formattedString) {
|
||||
if (formattedString == null || formattedString.isEmpty) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(formattedString).toLocal();
|
||||
return '${_fourDigits(dt.year)}-${_twoDigits(dt.month)}-${_twoDigits(dt.day)} '
|
||||
'${_twoDigits(dt.hour)}:${_twoDigits(dt.minute)}:${_twoDigits(dt.second)}';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// utc 转 MM月dd日
|
||||
static String utc2isoMD(String? formattedString) {
|
||||
if (formattedString == null || formattedString.isEmpty) return '';
|
||||
try {
|
||||
final dt = DateTime.parse(formattedString).toLocal();
|
||||
return '${_twoDigits(dt.month)}月${_twoDigits(dt.day)}日';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 2020-11-26T17:44:45.000Z
|
||||
static String? format2utc(DateTime dateTime) {
|
||||
return '${_fourDigits(dateTime.year)}-${_twoDigits(dateTime.month)}-${_twoDigits(dateTime.day)}'
|
||||
'T${_twoDigits(dateTime.hour)}:${_twoDigits(dateTime.minute)}:${_twoDigits(dateTime.second)}'
|
||||
'.${_threeDigits(dateTime.millisecond)}Z';
|
||||
}
|
||||
|
||||
/// 4 位补零(年份),负数前置符号,例:5 → "0005",-5 → "-0005"
|
||||
static String _fourDigits(int n) {
|
||||
final absN = n.abs();
|
||||
final sign = n < 0 ? '-' : '';
|
||||
if (absN >= 1000) return '$n';
|
||||
if (absN >= 100) return '${sign}0$absN';
|
||||
if (absN >= 10) return '${sign}00$absN';
|
||||
return '${sign}000$absN';
|
||||
}
|
||||
|
||||
/// 3 位补零(毫秒),例:5 → "005"
|
||||
static String _threeDigits(int n) {
|
||||
if (n >= 100) return '$n';
|
||||
if (n >= 10) return '0$n';
|
||||
return '00$n';
|
||||
}
|
||||
|
||||
/// 2 位补零(月/日/时/分/秒),例:5 → "05"
|
||||
static String _twoDigits(int n) {
|
||||
if (n >= 10) return '$n';
|
||||
return '0$n';
|
||||
}
|
||||
|
||||
/// utc 转 年月日
|
||||
static String utcTurnYear(String? date,
|
||||
{String? char, bool? isChina, bool showHM = false}) {
|
||||
if (date == null || date.isEmpty) return '';
|
||||
final gap = char ?? '-';
|
||||
final dt = DateTime.parse(date).toLocal();
|
||||
final y = _fourDigits(dt.year);
|
||||
final m = _twoDigits(dt.month);
|
||||
final d = _twoDigits(dt.day);
|
||||
if (isChina == true) return '$y年$m月$d日';
|
||||
if (showHM == true) {
|
||||
return '$y$gap$m$gap$d ${_twoDigits(dt.hour)}:${_twoDigits(dt.minute)}';
|
||||
}
|
||||
return '$y$gap$m$gap$d ';
|
||||
}
|
||||
|
||||
static String utc3YearMonthDay(DateTime dateTime) {
|
||||
try {
|
||||
return '${_twoDigits(dateTime.year)}-${_twoDigits(dateTime.month)}-${_twoDigits(dateTime.day)} ';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算字符串时间差值
|
||||
static String? calTimediffFromNow(String? date) {
|
||||
final time = DateTime.parse(date ?? '').toLocal();
|
||||
final difference = time.difference(DateTime.now());
|
||||
final day = difference.inDays;
|
||||
final hours = difference.inHours % 24;
|
||||
return '$day天$hours小时';
|
||||
}
|
||||
|
||||
/// 秒 → MM:SS / HH:MM:SS(小时补零)。[alwaysHour] 为 true 时不足 1 小时也带小时位(00:MM:SS)
|
||||
/// 时长格式化的唯一入口,外部各处统一走这里
|
||||
static String formatHMS(int seconds, {bool alwaysHour = false}) {
|
||||
if (seconds < 0) seconds = 0;
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
final ms = '${_twoDigits(m)}:${_twoDigits(s)}';
|
||||
return (h == 0 && !alwaysHour) ? ms : '${_twoDigits(h)}:$ms';
|
||||
}
|
||||
|
||||
/// 时长 → MM:SS / HH:MM:SS(统一走 [formatHMS])
|
||||
static String? formatDuration(Duration position) =>
|
||||
formatHMS(position.inSeconds);
|
||||
|
||||
/// 秒 → (时, 分, 秒) 各两位补零字符串;时为总小时数(不取模 24),供分段倒计时 UI 用
|
||||
static (String, String, String) hms(int seconds) => (
|
||||
_twoDigits(seconds ~/ 3600),
|
||||
_twoDigits((seconds ~/ 60) % 60),
|
||||
_twoDigits(seconds % 60),
|
||||
);
|
||||
|
||||
/// 这个时间是否**还没到期**(晚于服务器当前时间)。名字有歧义,别按字面理解成「已过期」。
|
||||
/// 解析不了一律当没权益:这里被登录/刷新用户信息那条主路调用(见 GlobalStore._setMe),
|
||||
/// 裸 DateTime.parse 遇到后端换格式会把整条链抛断,登录都进不去
|
||||
static bool isExpireDate(String? time) {
|
||||
if (time == null || time.isEmpty) return false;
|
||||
final dt = DateTime.tryParse(time);
|
||||
return dt != null && dt.isAfter(netManager.getFixedCurTime());
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建分秒 MM:SS
|
||||
String buildMMSS(int seconds) {
|
||||
final minute = seconds ~/ 60;
|
||||
final second = seconds % 60;
|
||||
return '${_formatTime(minute)}:${_formatTime(second)}';
|
||||
}
|
||||
|
||||
/// 数字格式化,将 0~9 的时间转换为 00~09
|
||||
String _formatTime(int timeNum) => timeNum.toString().padLeft(2, '0');
|
||||
|
||||
const String kFormatTimeFallback = '—';
|
||||
|
||||
/// 最早可展示时间(早于该时间视为非正常)
|
||||
final DateTime _kMinValidDateTime = DateTime(1970, 1, 1);
|
||||
|
||||
/// 非正常时间兜底:占位默认时间、过早/未来时间等不可用于展示
|
||||
bool _isAbnormalDateTime(DateTime date, {DateTime? now}) {
|
||||
if (date.year <= 1) return true;
|
||||
if (date.isBefore(_kMinValidDateTime)) return true;
|
||||
final reference = now ?? DateTime.now();
|
||||
if (date.isAfter(reference)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 解析时间;空值、解析失败、非正常时间返回 null
|
||||
DateTime? _parseFormatTimeDate(String? utcTime) {
|
||||
if (utcTime == null || utcTime.trim().isEmpty) return null;
|
||||
try {
|
||||
final date = DateTime.parse(utcTime.trim()).toLocal();
|
||||
if (_isAbnormalDateTime(date)) return null;
|
||||
return date;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
/// 1 小时内 →「X分钟前」;当天 →「HH:mm」;其余 →「MM-DD HH:mm」;非正常时间 →「—」
|
||||
String formatTime(String? utcTime,
|
||||
{String gap = '-', bool hasTimeGap = false}) {
|
||||
final date = _parseFormatTimeDate(utcTime);
|
||||
if (date == null) return kFormatTimeFallback;
|
||||
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inHours < 1) {
|
||||
final minutes = diff.inMinutes;
|
||||
return minutes < 1 ? '1分钟前' : '$minutes分钟前';
|
||||
}
|
||||
|
||||
if (_isSameDay(date, now)) {
|
||||
return '${DateTimeUtil._twoDigits(date.hour)}:${DateTimeUtil._twoDigits(date.minute)}';
|
||||
}
|
||||
|
||||
return '${DateTimeUtil._twoDigits(date.month)}$gap${DateTimeUtil._twoDigits(date.day)} '
|
||||
'${DateTimeUtil._twoDigits(date.hour)}:${DateTimeUtil._twoDigits(date.minute)}';
|
||||
}
|
||||
|
||||
String formatTimeTwo(String? utcTime) {
|
||||
if (utcTime?.isNotEmpty != true) return '近期';
|
||||
final now = DateTime.now();
|
||||
final date = DateTime.parse(utcTime!).toLocal();
|
||||
final changeTime =
|
||||
(now.millisecondsSinceEpoch - date.millisecondsSinceEpoch) ~/ 1000;
|
||||
|
||||
final s = changeTime;
|
||||
if (s >= 0 && s < 60) return '刚刚';
|
||||
final m = s ~/ 60;
|
||||
if (m > 0 && m < 60) return '$m分钟前';
|
||||
final h = m ~/ 60;
|
||||
if (h > 0 && h < 24) return '$h小时前';
|
||||
final d = h ~/ 24;
|
||||
if (d > 0 && d < 7) return '$d天前';
|
||||
|
||||
final w = d ~/ 7;
|
||||
if (w > 0 && w < 4) return '$w周前';
|
||||
|
||||
final month = d ~/ 30;
|
||||
if (month > 0 && month < 12) return DateTimeUtil.utc2isoMD(utcTime);
|
||||
|
||||
final year = month ~/ 12;
|
||||
if (year > 0 && year < 12)
|
||||
return DateTimeUtil.utcTurnYear(utcTime, isChina: true);
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/dns_solve/dnsolve.dart';
|
||||
import 'package:hgdj/hj_utils/light_model.dart';
|
||||
import 'package:hgdj/hj_utils/store_keys.dart';
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
//选线管理
|
||||
class DetectLineManager {
|
||||
// 在途请求的取消列表,超时时统一取消
|
||||
final _cancelList = <CancelToken>[];
|
||||
|
||||
Future<String> detectLineOnce() async {
|
||||
// 硬兜底:整个选线流程最多 20s,超时按"无可用线路"处理,避免 UI 永久卡在"选线中..."
|
||||
String line;
|
||||
try {
|
||||
line = await _detectLineOnce().timeout(const Duration(seconds: 20));
|
||||
} catch (e) {
|
||||
debugLog("selectLine", "detectLineOnce()...timeout or error:$e");
|
||||
line = "";
|
||||
}
|
||||
_cancelInflight();
|
||||
_cancelList.clear();
|
||||
return line;
|
||||
}
|
||||
|
||||
// 取消在途请求;except 为本次胜出的线路,不取消自己
|
||||
void _cancelInflight({CancelToken? except}) {
|
||||
for (final t in _cancelList) {
|
||||
if (t != except) t.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次完整的批量选线过程
|
||||
Future<String> _detectLineOnce() async {
|
||||
final saved = await lightKV.getStringList(StoreKeys.DETECT_LINE);
|
||||
final lines = saved?.isNotEmpty == true ? saved! : Config.lineList;
|
||||
var successLine = 'https://d1s84171319hi1.cloudfront.net';
|
||||
// 本地线路全部失败,回退 DNS 解析
|
||||
if (TextUtil.isEmpty(successLine)) {
|
||||
successLine = await _dnsSolve();
|
||||
}
|
||||
return successLine;
|
||||
}
|
||||
|
||||
///检查一批线路:并发 ping,谁先成功用谁;全失败返回 ""
|
||||
Future<String> _pingCheckBatch(List<String> lines) async {
|
||||
final validLines = lines.where(TextUtil.isNotEmpty).toList();
|
||||
if (validLines.isEmpty) return "";
|
||||
|
||||
final completer = Completer<String>();
|
||||
final tasks = validLines.map((line) async {
|
||||
debugLog("selectLine", "_pingCheckBatch()...开始选线:$line");
|
||||
final cancelToken = CancelToken();
|
||||
_cancelList.add(cancelToken);
|
||||
try {
|
||||
if (await _pingCheck(line, cancelToken)) {
|
||||
if (!completer.isCompleted) {
|
||||
debugLog("selectLine", "_pingCheckBatch()...选线成功:$line");
|
||||
completer.complete(line);
|
||||
// 竞速:已选到最快线路,立即取消其余在途请求
|
||||
_cancelInflight(except: cancelToken);
|
||||
}
|
||||
} else {
|
||||
debugLog("selectLine", "_pingCheckBatch()...线路不可用:$line");
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog("selectLine", "_pingCheckBatch()...线路异常:$line $e");
|
||||
} finally {
|
||||
_cancelList.remove(cancelToken);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// 全部结束仍无成功 → 返回空
|
||||
Future.wait(tasks).whenComplete(() {
|
||||
if (!completer.isCompleted) completer.complete("");
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// 单次网络请求,业务码 200 才算成功
|
||||
Future<bool> _pingCheck(String line, [CancelToken? cancelToken]) async {
|
||||
final startTime = DateTime.now();
|
||||
final resp = await httpManager.fetchDetectLineResponse(
|
||||
"$line/api/app/ping/check",
|
||||
options: Options(
|
||||
method: "GET",
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
debugLog("ping",
|
||||
"pingCheck()...line:$line cost ${DateTime.now().difference(startTime).inMilliseconds} milSeconds");
|
||||
if (TextUtil.isNotEmpty(resp.time)) {
|
||||
httpManager.setServerTime(resp.time);
|
||||
}
|
||||
return resp.isSuccess;
|
||||
}
|
||||
|
||||
//本地域名不通的情况下,使用 dns 解析
|
||||
Future<String> _dnsSolve() async {
|
||||
final response = await DNSolve().lookup(
|
||||
Config.dns,
|
||||
dnsSec: true,
|
||||
type: RecordType.txt,
|
||||
provider: DNSProvider.aliyun,
|
||||
);
|
||||
|
||||
//必须先取出来判空,不能写成 `?? []`:那个空字面量会被推成 List<dynamic>,
|
||||
//整行 LUB 退化后 record 也成了 dynamic,record.data.split().map().toList()
|
||||
//一路 dynamic 下去,运行期是 List<dynamic>,传给 setStringList(List<String>) 直接抛。
|
||||
//全程 dynamic,analyze 一个警告都不会给
|
||||
final records = response.answer?.records;
|
||||
if (records == null) return '';
|
||||
for (final record in records) {
|
||||
if (record.data.isEmpty) continue;
|
||||
debugLog("selectLine", "dnsSolve ==== ${record.data}");
|
||||
//DNS TXT 值带引号,去掉后按 _ 拆成多条线路
|
||||
final lines = record.data
|
||||
.split('_')
|
||||
.map((e) => e.replaceAll("\"", ""))
|
||||
.where(TextUtil.isNotEmpty)
|
||||
.toList();
|
||||
if (lines.isEmpty) continue; // 这条记录没解析出线路,接着看下一条,别直接放弃
|
||||
lightKV.setStringList(StoreKeys.DETECT_LINE, lines);
|
||||
return await _pingCheckBatch(lines);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
part of '_dnsolve.dart';
|
||||
|
||||
/// Represents an answer containing a list of generic records and a list of
|
||||
/// Service (SRV) records parsed from JSON data.
|
||||
class _Answer {
|
||||
const _Answer(this.records, [this.srvs]);
|
||||
|
||||
/// List of generic records.
|
||||
final List<_Record>? records;
|
||||
|
||||
/// List of Service (SRV) records.
|
||||
final List<SRVRecord>? srvs;
|
||||
|
||||
/// Constructs an [_Answer] instance from JSON data.
|
||||
///
|
||||
/// The [json] parameter should be a list of dynamic objects representing
|
||||
/// DNS records. Returns an [_Answer] instance containing parsed records
|
||||
/// and Service (SRV) records.
|
||||
factory _Answer.fromJson(List<dynamic>? json) {
|
||||
if (json == null) {
|
||||
return const _Answer(null);
|
||||
}
|
||||
|
||||
final records = json
|
||||
.map((answer) => _Record.fromJson(answer as Map<String, dynamic>))
|
||||
.toList();
|
||||
final srvs = <SRVRecord>[];
|
||||
|
||||
{
|
||||
final RegExp regExp = RegExp(r'(\d+)\s+(\d+)\s+(\d+)\s+([\w\.\-]+)');
|
||||
for (final record in records) {
|
||||
if (record.rType == RecordType.srv) {
|
||||
final match = regExp.firstMatch(record.data);
|
||||
|
||||
if (match != null) {
|
||||
final priority = int.parse(match.group(1)!);
|
||||
final weight = int.parse(match.group(2)!);
|
||||
final port = int.parse(match.group(3)!);
|
||||
final target = match.group(4)!;
|
||||
|
||||
srvs.add(
|
||||
SRVRecord(
|
||||
priority: priority,
|
||||
weight: weight,
|
||||
port: port,
|
||||
target: target,
|
||||
fqdn: record.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw const SRVRecordFormatException(
|
||||
'Failed to parse or process the Service (SRV) record',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _Answer(records, srvs);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '''$records''';
|
||||
}
|
||||
|
||||
class _Record {
|
||||
const _Record({
|
||||
required this.name,
|
||||
required this.rType,
|
||||
required this.ttl,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final RecordType rType;
|
||||
final int ttl;
|
||||
final String data;
|
||||
|
||||
factory _Record.fromJson(Map<String, dynamic> json) => _Record(
|
||||
name: json['name'] as String,
|
||||
rType: DNSolve.intToRecord(json['type'] as int),
|
||||
ttl: json['TTL'] as int,
|
||||
data: json['data'] as String,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'''(name: $name, type: $rType, ttl: $ttl, data: $data)''';
|
||||
|
||||
String get toBind {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write(name);
|
||||
if (buffer.length < 8) {
|
||||
buffer.write('\t');
|
||||
}
|
||||
if (buffer.length > 10) {
|
||||
buffer.write('\t');
|
||||
}
|
||||
buffer.writeAll(
|
||||
[ttl, '\tIN\t', rType.name.toUpperCase(), '\t', '"', data, '"'],
|
||||
);
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a Service (SRV) record containing information about a server or
|
||||
/// service in the domain name system (DNS).
|
||||
class SRVRecord {
|
||||
/// Constructs an [SRVRecord] with the specified parameters.
|
||||
const SRVRecord({
|
||||
required this.priority,
|
||||
required this.weight,
|
||||
required this.port,
|
||||
this.target,
|
||||
required this.fqdn,
|
||||
});
|
||||
|
||||
/// The priority of this SRV record.
|
||||
final int priority;
|
||||
|
||||
/// The weight of this SRV record.
|
||||
final int weight;
|
||||
|
||||
/// The port on which the service is available.
|
||||
final int port;
|
||||
|
||||
/// The target domain name of the server.
|
||||
final String? target;
|
||||
|
||||
/// Fully Qualified Domain Name.
|
||||
final String fqdn;
|
||||
|
||||
/// Sorts a list of [SRVRecord] instances based on their priority and weight.
|
||||
static List<SRVRecord> sort(List<SRVRecord> records) {
|
||||
records.sort(_srvRecordSortComparator);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/// Comparator function for sorting [SRVRecord] instances.
|
||||
static int _srvRecordSortComparator(SRVRecord a, SRVRecord b) {
|
||||
if (a.priority < b.priority) {
|
||||
return -1;
|
||||
} else {
|
||||
if (a.priority > b.priority) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.weight < b.weight) {
|
||||
return -1;
|
||||
} else if (a.weight > b.weight) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is SRVRecord &&
|
||||
other.runtimeType == runtimeType &&
|
||||
other.priority == priority &&
|
||||
other.weight == weight &&
|
||||
other.port == port &&
|
||||
other.target == other.target;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(priority, weight, port, target);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'exception.dart';
|
||||
|
||||
part '_answer.dart';
|
||||
part '_question.dart';
|
||||
part '_response.dart';
|
||||
|
||||
/// An enumeration that represents various DNS record types.
|
||||
enum RecordType {
|
||||
A,
|
||||
aaaa,
|
||||
any,
|
||||
caa,
|
||||
cds,
|
||||
cert,
|
||||
cname,
|
||||
dname,
|
||||
dnskey,
|
||||
ds,
|
||||
hinfo,
|
||||
ipseckey,
|
||||
nsec,
|
||||
nsec3PARAM,
|
||||
naptr,
|
||||
ptr,
|
||||
rp,
|
||||
rrsig,
|
||||
soa,
|
||||
spf,
|
||||
srv,
|
||||
sshfp,
|
||||
tlsa,
|
||||
wks,
|
||||
txt,
|
||||
ns,
|
||||
mx,
|
||||
}
|
||||
|
||||
/// An enumeration that represents different DNS service providers.
|
||||
enum DNSProvider { google, cloudflare, aliyun }
|
||||
|
||||
class DNSolve {
|
||||
DNSolve() {
|
||||
_client = http.Client();
|
||||
}
|
||||
|
||||
late final http.Client _client;
|
||||
|
||||
/// A map that associates [DNSProvider] enum values with their respective DNS
|
||||
/// provider URLs.
|
||||
static const _dnsProviders = <DNSProvider, String>{
|
||||
DNSProvider.google: 'https://dns.google.com/resolve',
|
||||
DNSProvider.cloudflare: 'https://cloudflare-dns.com/dns-query',
|
||||
DNSProvider.aliyun: 'https://dns.alidns.com/resolve'
|
||||
};
|
||||
|
||||
/// Performs a DNS lookup for the given domain.
|
||||
Future<ResolveResponse> lookup(
|
||||
/// The domain to lookup.
|
||||
String domain, {
|
||||
/// Whether to enable DNSSEC (Domain Name System Security Extensions).
|
||||
bool dnsSec = false,
|
||||
|
||||
/// The DNS record type to look up (defaults to A).
|
||||
RecordType type = RecordType.A,
|
||||
|
||||
/// The DNS provider to use (defaults to Google).
|
||||
DNSProvider provider = DNSProvider.google,
|
||||
}) async {
|
||||
assert(domain.isNotEmpty, 'domain should not be empty');
|
||||
|
||||
final queryParams = <String, String>{};
|
||||
queryParams
|
||||
..putIfAbsent('name', () => domain)
|
||||
..putIfAbsent('type', () => _typeToInt(type).toString())
|
||||
..putIfAbsent('dnssec', () => dnsSec.toString());
|
||||
|
||||
final headers = <String, String>{'Accept': 'application/dns-json'};
|
||||
final url = _dnsProviders[provider] ?? 'https://dns.google.com/resolve';
|
||||
|
||||
final body =
|
||||
await _get(url, queryParameters: queryParams, headers: headers);
|
||||
|
||||
return ResolveResponse.fromJson(json.decode(body) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Performs a reverse DNS lookup for the given IP address.
|
||||
Future<List<_Record>> reverseLookup(
|
||||
/// The IP address to perform a reverse lookup for.
|
||||
String ip, {
|
||||
/// THE DNS provider to use (defaults to Google).
|
||||
DNSProvider provider = DNSProvider.google,
|
||||
}) async {
|
||||
final queryParams = <String, String>{};
|
||||
String? reverse() {
|
||||
if (ip.contains('.')) {
|
||||
return '${ip.split('.').reversed.join('.')}.in-addr.arpa';
|
||||
} else if (ip.contains(':')) {
|
||||
return '${ip.split(':').join().split('').reversed.join('.')}.ip6.arpa';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final reversed = reverse();
|
||||
if (reversed == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
queryParams
|
||||
..putIfAbsent('name', () => reversed)
|
||||
..putIfAbsent('type', () => _records[RecordType.ptr]!.toString());
|
||||
|
||||
final headers = <String, String>{'Accept': 'application/dns-json'};
|
||||
final url = _dnsProviders[provider] ?? 'https://dns.google.com/resolve';
|
||||
|
||||
final body =
|
||||
await _get(url, queryParameters: queryParams, headers: headers);
|
||||
final response =
|
||||
ResolveResponse.fromJson(json.decode(body) as Map<String, dynamic>);
|
||||
return response.answer!.records ?? [];
|
||||
}
|
||||
|
||||
/// Sends an HTTP GET request to the specified URL with optional query
|
||||
/// parameters and headers.
|
||||
Future<String> _get(
|
||||
String url, {
|
||||
Map<String, String>? queryParameters,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
late Uri uri;
|
||||
{
|
||||
if (queryParameters == null || queryParameters.isEmpty) {
|
||||
uri = Uri.parse(url);
|
||||
} else {
|
||||
uri = Uri.parse(url).replace(queryParameters: queryParameters);
|
||||
}
|
||||
}
|
||||
|
||||
final response = await _client.get(uri, headers: headers);
|
||||
return _handleResponse(response);
|
||||
}
|
||||
|
||||
/// A map that associates RecordType enum values with their corresponding DNS
|
||||
/// record types (integer values).
|
||||
static const _records = {
|
||||
RecordType.A: 1,
|
||||
RecordType.aaaa: 28,
|
||||
RecordType.any: 255,
|
||||
RecordType.caa: 257,
|
||||
RecordType.cds: 59,
|
||||
RecordType.cert: 37,
|
||||
RecordType.cname: 5,
|
||||
RecordType.dname: 39,
|
||||
RecordType.dnskey: 48,
|
||||
RecordType.ds: 43,
|
||||
RecordType.hinfo: 13,
|
||||
RecordType.ipseckey: 45,
|
||||
RecordType.mx: 15,
|
||||
RecordType.naptr: 35,
|
||||
RecordType.ns: 2,
|
||||
RecordType.nsec: 47,
|
||||
RecordType.nsec3PARAM: 51,
|
||||
RecordType.ptr: 12,
|
||||
RecordType.rp: 17,
|
||||
RecordType.rrsig: 46,
|
||||
RecordType.soa: 6,
|
||||
RecordType.spf: 99,
|
||||
RecordType.srv: 33,
|
||||
RecordType.sshfp: 44,
|
||||
RecordType.tlsa: 52,
|
||||
RecordType.txt: 16,
|
||||
RecordType.wks: 11,
|
||||
};
|
||||
|
||||
/// Converts an integer DNS record type to a [RecordType] enum value.
|
||||
static RecordType intToRecord(int type) {
|
||||
final records = _records.map((key, value) => MapEntry(value, key));
|
||||
|
||||
return records[type] ?? RecordType.A;
|
||||
}
|
||||
|
||||
/// Converts a [RecordType] enum value to its corresponding integer DNS record
|
||||
/// type.
|
||||
static int _typeToInt(RecordType type) => _records[type] ?? 1;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
part of '_dnsolve.dart';
|
||||
|
||||
class _Question {
|
||||
const _Question({required this.name, required this.rType});
|
||||
|
||||
final String? name;
|
||||
final RecordType? rType;
|
||||
|
||||
factory _Question.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return const _Question(name: null, rType: null);
|
||||
}
|
||||
|
||||
return _Question(
|
||||
name: json['name'] as String,
|
||||
rType: DNSolve.intToRecord(json['type'] as int),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '''(name: $name, rType: $rType)''';
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
part of '_dnsolve.dart';
|
||||
|
||||
String _handleResponse(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode <= 209) {
|
||||
return response.body;
|
||||
} else {
|
||||
throw ResponseException(
|
||||
body: response.body,
|
||||
headers: response.headers,
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a response from a DNS resolution operation.
|
||||
///
|
||||
/// This class includes information about the resolution status, flags,
|
||||
/// comments, resolved answer, and the list of questions queried.
|
||||
class ResolveResponse {
|
||||
const ResolveResponse({
|
||||
required this.status,
|
||||
required this.tc,
|
||||
required this.rd,
|
||||
required this.ra,
|
||||
required this.ad,
|
||||
required this.cd,
|
||||
required this.comment,
|
||||
required this.answer,
|
||||
required this.questions,
|
||||
});
|
||||
|
||||
/// The status code indicating the result of the DNS resolution.
|
||||
final int? status;
|
||||
|
||||
/// Indicates if the response was truncated.
|
||||
final bool? tc;
|
||||
|
||||
/// Indicates if recursion was desired in the request.
|
||||
final bool? rd;
|
||||
|
||||
/// Indicates if recursion is available in the response.
|
||||
final bool? ra;
|
||||
|
||||
/// Indicates if the data in the response is authenticated.
|
||||
final bool? ad;
|
||||
|
||||
/// Indicates if checking is disabled in the response.
|
||||
final bool? cd;
|
||||
|
||||
/// Additional comments or information related to the resolution response.
|
||||
final String? comment;
|
||||
|
||||
/// The resolved answer containing DNS records.
|
||||
final _Answer? answer;
|
||||
|
||||
/// List of questions queried in the resolution request.
|
||||
final List<_Question>? questions;
|
||||
|
||||
/// Constructs a [ResolveResponse] instance from JSON data.
|
||||
///
|
||||
/// The [json] parameter should be a map containing the fields of a DNS
|
||||
/// resolution response. Returns a [ResolveResponse] instance with parsed
|
||||
/// data.
|
||||
factory ResolveResponse.fromJson(Map<String, dynamic> json) => ResolveResponse(
|
||||
status: json['Status'] as int?,
|
||||
tc: json['TC'] as bool?,
|
||||
rd: json['RD'] as bool?,
|
||||
ra: json['RA'] as bool?,
|
||||
ad: json['AD'] as bool?,
|
||||
cd: json['CD'] as bool?,
|
||||
comment: json['comment'] as String?,
|
||||
answer: _Answer.fromJson(json['Answer'] as List<dynamic>?),
|
||||
questions: () {
|
||||
final data = json['Question'];
|
||||
if (data == null) return null;
|
||||
if (data is List) {
|
||||
return (data as List)
|
||||
.map(
|
||||
(question) => _Question.fromJson(question as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
if (data is Map) return [_Question.fromJson(Map<String, dynamic>.from(data))];
|
||||
}(),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'''status: $status, truncation: $tc, recursion desired(rd): $rd, recursion available(ra): $ra, authenticated data(ad): $ad, checking disabled(cd): $cd, comment: $comment, answer: $answer, questions: $questions''';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// Provider of an easy way of performing DNS lookups.
|
||||
library;
|
||||
|
||||
export '_dnsolve.dart';
|
||||
export 'exception.dart';
|
||||
@@ -0,0 +1,42 @@
|
||||
/// An abstract class representing an exception related to DNS solving.
|
||||
///
|
||||
/// This serves as a base class for exceptions that may occur during DNS
|
||||
/// resolution or parsing operations.
|
||||
abstract class DNSolveException implements Exception {
|
||||
const DNSolveException();
|
||||
}
|
||||
|
||||
/// Represents an [Exception] that occured while processing an DNS request.
|
||||
///
|
||||
/// It contains information about the status code, headers, and body of the
|
||||
/// response.
|
||||
class ResponseException extends DNSolveException {
|
||||
const ResponseException({
|
||||
required this.statusCode,
|
||||
required this.headers,
|
||||
required this.body,
|
||||
}) : super();
|
||||
|
||||
/// The status code of the response.
|
||||
final int statusCode;
|
||||
|
||||
/// The headers of the response.
|
||||
final Map<String, String> headers;
|
||||
|
||||
/// The body of the response.
|
||||
final String body;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'''Exception(Status Code: $statusCode, Response Headers: $headers, Response Body: $body)''';
|
||||
}
|
||||
|
||||
/// An exception indicating that an error occurred while parsing or processing a
|
||||
/// Service (SRV) record.
|
||||
///
|
||||
/// This is a specific type of [DNSolveException].
|
||||
class SRVRecordFormatException extends DNSolveException {
|
||||
const SRVRecordFormatException(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
const KB_SIZE = 1024;
|
||||
const MB_SIZE = 1024 * KB_SIZE;
|
||||
const GB_SIZE = 1024 * MB_SIZE;
|
||||
|
||||
/// 文件相关的公共处理
|
||||
class FileUtil {
|
||||
/// 计算切片个数
|
||||
static int getPatchCount(int fileLen) {
|
||||
final cutSize = getPatchSize(fileLen);
|
||||
return (fileLen + cutSize - 1) ~/ cutSize;
|
||||
}
|
||||
|
||||
/// 根据文件长度计算切片大小
|
||||
static int getPatchSize(int fileLen) {
|
||||
if (fileLen < MB_SIZE) return MB_SIZE;
|
||||
return 2 * MB_SIZE;
|
||||
}
|
||||
|
||||
/// 文件是否存在
|
||||
static bool isFileExist(String path) {
|
||||
return TextUtil.isNotEmpty(path) && File(path).existsSync();
|
||||
}
|
||||
|
||||
/// 获取 file 从 offset 之后到 blockSize 的数据块
|
||||
/// [offset] 起始偏移位置
|
||||
/// [blockSize] 分块大小
|
||||
/// [file] 文件
|
||||
static Future<Uint8List> getFileBlock(
|
||||
int offset, int blockSize, File file) async {
|
||||
RandomAccessFile? accessFile;
|
||||
try {
|
||||
accessFile = await file.open();
|
||||
await accessFile.setPosition(offset);
|
||||
debugLog('offset:$offset blocksize:$blockSize');
|
||||
return await accessFile.read(blockSize);
|
||||
} on Exception {
|
||||
return Uint8List(0);
|
||||
} finally {
|
||||
accessFile?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文件长度
|
||||
static int getFileSize(String path) {
|
||||
if (!isFileExist(path)) return 0;
|
||||
return File(path).lengthSync();
|
||||
}
|
||||
|
||||
/// 获取文件的格式化大小
|
||||
static String byteFmt(int size) {
|
||||
if (size > GB_SIZE) {
|
||||
return '${(size / GB_SIZE).toStringAsFixed(1)}GB';
|
||||
} else if (size > MB_SIZE) {
|
||||
return '${(size / MB_SIZE).toStringAsFixed(1)}MB';
|
||||
} else {
|
||||
return '${(size / KB_SIZE).toStringAsFixed(1)}KB';
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文件名带后缀
|
||||
/// 支持 url/uri/file/abspath
|
||||
static String getName(String absPath) {
|
||||
if (TextUtil.isEmpty(absPath)) return absPath;
|
||||
absPath = Uri.parse(absPath).path;
|
||||
final start = absPath.lastIndexOf('/');
|
||||
if (start <= 0 || start == absPath.length - 1) {
|
||||
return absPath;
|
||||
}
|
||||
return absPath.substring(start + 1);
|
||||
}
|
||||
|
||||
/// 获取文件名不带后缀
|
||||
static String getNamePrefix(String absPath) {
|
||||
final name = getName(absPath);
|
||||
if (TextUtil.isEmpty(name)) return name;
|
||||
final ar = name.split('.');
|
||||
if (ar.empty()) return name;
|
||||
return ar[0];
|
||||
}
|
||||
|
||||
/// 获取文件名后缀 (fileExtension)
|
||||
static String getNameSuffix(String absPath) {
|
||||
final name = getName(absPath);
|
||||
if (TextUtil.isEmpty(name)) return name;
|
||||
final ar = name.split('.');
|
||||
if (ar.empty()) return name;
|
||||
if (ar.length < 2) return ar[0];
|
||||
return ar.last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
import '../hj_model/splash/watch_count_model.dart';
|
||||
import '../hj_model/video_model.dart';
|
||||
import '../tools_base/global_store/store.dart';
|
||||
import 'api_service/vid_service.dart';
|
||||
import 'light_model.dart';
|
||||
import 'store_keys.dart';
|
||||
|
||||
/// 非 VIP 用户的免费观看次数管理(全站唯一)
|
||||
class FreePlayManager {
|
||||
static final FreePlayManager _instance = FreePlayManager._();
|
||||
factory FreePlayManager() => _instance;
|
||||
FreePlayManager._();
|
||||
|
||||
/// 剩余免费观看次数(服务端下发)。别叫 playCount——全站的 playCount 都是视频播放量,会看串
|
||||
WatchCount? remain;
|
||||
|
||||
/// 已消费过免费次数的视频 ID(本地缓存,同一视频不重复扣)
|
||||
final usedIds = <String>[];
|
||||
|
||||
/// 次数/已看列表变更版本号:列表角标监听它,会话内试看次数用尽即刷新,不必重拉列表。
|
||||
final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||
|
||||
/// 已有一次帧末刷新在排队,避免同一帧重复注册 postFrameCallback
|
||||
bool _revisionScheduled = false;
|
||||
|
||||
/// 通知列表角标刷新。useFreePlay 常在 widget build 内被调用(遮罩/菜单/长视频状态计算里都会扣次),
|
||||
/// 若正处于 build/layout/paint 阶段直接改 ValueNotifier,会让监听的 cell 本帧 setState 报错,
|
||||
/// 故这些阶段推迟到帧末再通知;空闲/帧末阶段则立即通知。
|
||||
void _bumpRevision() {
|
||||
final phase = SchedulerBinding.instance.schedulerPhase;
|
||||
final safeNow = phase == SchedulerPhase.idle ||
|
||||
phase == SchedulerPhase.postFrameCallbacks;
|
||||
if (safeNow) {
|
||||
revision.value++;
|
||||
return;
|
||||
}
|
||||
if (_revisionScheduled) return;
|
||||
_revisionScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_revisionScheduled = false;
|
||||
revision.value++;
|
||||
});
|
||||
}
|
||||
|
||||
/// 拉账户免费次数 + 本地已看列表。切号后必须重调——次数是账户维度的,不重拉会一直用着上个账号的。
|
||||
/// 合并去重而不是清空重填:两个 await 期间可能有视频正好扣了次数 add 进来,清掉会导致它被重复扣
|
||||
Future<void> refresh() async {
|
||||
remain = await VidService.fetchWatchCount();
|
||||
final saved =
|
||||
await lightKV.getStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS) ??
|
||||
const <String>[];
|
||||
usedIds.addAll(saved.where((e) => !usedIds.contains(e)));
|
||||
_bumpRevision();
|
||||
}
|
||||
|
||||
/// 该视频当前是否仍应展示「免费试看」角标(无扣次副作用,供列表实时判断):
|
||||
/// - 后端未下发该角标:不展示;
|
||||
/// - 次数未知(remain 未拉到):信任后端下发的角标,避免误隐藏;
|
||||
/// - 次数已知:仅在还有剩余(>0)时展示,用尽即隐藏(含已试看过的视频,一并回落到真实角标)。
|
||||
bool canShowFreeTrialBadge(VideoModel? video) {
|
||||
if (video?.showFreeTrialBadge != true) return false;
|
||||
final left = remain?.watchCount;
|
||||
if (left == null) return true;
|
||||
return left > 0;
|
||||
}
|
||||
|
||||
/// 尝试消费一次免费观看权益,返回本视频能否免费播放。
|
||||
/// ⚠️ 有副作用:首次命中会扣次数 + 写本地缓存 + 上报服务端;
|
||||
/// 同一视频再次调用命中 [usedIds] 直接返回 true,不重复扣。
|
||||
bool useFreePlay(VideoModel? video) {
|
||||
final id = video?.id;
|
||||
if (video == null || id == null || id.isEmpty) return false;
|
||||
if (video.freeArea == true || video.isCoinVideo() == true) return false;
|
||||
if (globalStore.isVIP) return false;
|
||||
final count = remain;
|
||||
if (count == null) return false;
|
||||
if (usedIds.contains(id)) return true;
|
||||
|
||||
final left = count.watchCount ?? 0;
|
||||
if (left <= 0) {
|
||||
//先算后显示,0 要显示成 1;服务端没次数了下发的就是 0,这里得继续往下减
|
||||
count.watchCount = left - 1;
|
||||
// 不 bump:此时次数早已<=0、角标已隐藏(隐藏那次跃迁在下面 1→0 的成功分支已通知)。
|
||||
// 该分支会被播放页多处 build 反复命中,若再 bump 会导致首页 cell 每帧无谓重建。
|
||||
return false;
|
||||
}
|
||||
count.watchCount = left - 1;
|
||||
usedIds.add(id);
|
||||
_bumpRevision(); // 扣次成功(含 1→0 那次):剩余变化,刷新列表角标;同一视频后续走 usedIds 早返回不再 bump
|
||||
// 后台上报,成功才落本地缓存;失败只记日志、不影响本次放行
|
||||
// 注:上报是异步的,异常只能用 catchError 接(try-catch 包不住 then 里的异步错误)
|
||||
VidService.fetchWatchCount(vid: video.subid ?? id).then<void>((value) {
|
||||
if (value != null)
|
||||
lightKV.setStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS, usedIds);
|
||||
}).catchError((Object e) => debugLog('免费次数上报失败', e));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgdj/hj_model/home/collection_model.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/base_history_record_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/cartoon_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/comics_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/community_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/game_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/novel_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/pic_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/search_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/short_video_history_store.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/video_history_store.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
|
||||
import '../hj_model/cartoon_media_info.dart';
|
||||
|
||||
/// 浏览历史 + 搜索历史的统一入口。按 [MediaStyle] 派发到各自的 store,业务层不直接碰 store
|
||||
class HistoryUtil {
|
||||
/// 每种类型独占一个 db 文件,store 自身是单例。返回 null = 该类型不记历史
|
||||
static BaseHistoryRecordStore? _storeOf(MediaStyle type) => switch (type) {
|
||||
MediaStyle.Video => VideoHistoryStore(),
|
||||
MediaStyle.ShortVideo => ShortVideoHistoryStore(),
|
||||
MediaStyle.Comics => ComicsHistoryStore(),
|
||||
MediaStyle.Cartoon => CartoonHistoryStore(),
|
||||
MediaStyle.Novel => NovelHistoryStore(),
|
||||
MediaStyle.Community => CommunityHistoryStore(),
|
||||
MediaStyle.Pic => PicHistoryStore(),
|
||||
MediaStyle.Game => GameHistoryStore(),
|
||||
// 短剧的观看历史和续播位置是同一张表,走 DramaResumeStore;其余是当前业务无浏览历史需求的类型
|
||||
MediaStyle.Drama ||
|
||||
MediaStyle.Area ||
|
||||
MediaStyle.Seed ||
|
||||
MediaStyle.Actress ||
|
||||
MediaStyle.NakedChat ||
|
||||
MediaStyle.GroupChat =>
|
||||
null,
|
||||
};
|
||||
|
||||
/// 插入浏览历史(已存在则刷新到最前;超上限自动删最旧,上限见 BaseHistoryRecordStore.maxRows)
|
||||
static Future<void> insert(Object? model, MediaStyle type) async {
|
||||
try {
|
||||
final json = _toJson(model);
|
||||
if (json == null) {
|
||||
//类型没在 _toJson/_idOf 里登记 → 静默不记历史。入参是 Object? 编译期拦不住,只能靠日志暴露
|
||||
debugLog('HistoryUtil.insert 未登记的类型', model?.runtimeType);
|
||||
return;
|
||||
}
|
||||
final id = _idOf(model);
|
||||
final store = _storeOf(type);
|
||||
if (id == null || id.isEmpty || store == null) return;
|
||||
await store.save(modelId: id, modelDataJson: jsonEncode(json));
|
||||
} catch (e) {
|
||||
debugLog('HistoryUtil.insert', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除单条浏览历史
|
||||
static Future<void> delete(Object? model, MediaStyle type) async {
|
||||
try {
|
||||
final id = _idOf(model);
|
||||
if (id == null) {
|
||||
debugLog('HistoryUtil.delete 未登记的类型', model?.runtimeType);
|
||||
return;
|
||||
}
|
||||
final store = _storeOf(type);
|
||||
if (id.isEmpty || store == null) return;
|
||||
await store.remove(id);
|
||||
} catch (e) {
|
||||
debugLog('HistoryUtil.delete', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定类型的浏览历史(按时间倒序,分页)。解析不出的条目直接丢弃,不让一条脏数据废掉整页
|
||||
static Future<List<T>> fetch<T>(MediaStyle type,
|
||||
{int page = 1, int pageSize = 20}) async {
|
||||
try {
|
||||
final store = _storeOf(type);
|
||||
if (store == null) return <T>[];
|
||||
final jsonList = await store.fetch(page: page, pageSize: pageSize);
|
||||
return jsonList.map((e) => _fromJson<T>(e)).whereType<T>().toList();
|
||||
} catch (e) {
|
||||
debugLog('HistoryUtil.fetch', e);
|
||||
return <T>[];
|
||||
}
|
||||
}
|
||||
|
||||
/// 三个 _xxx 按实例匹配,新增可记历史的 model 时这里和 [_fromJson] 一起加一行
|
||||
static String? _idOf(Object? model) => switch (model) {
|
||||
VideoModel m => m.id,
|
||||
CartoonMediaInfo m => m.id,
|
||||
CollectionModel m => m.id,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
static Map<String, dynamic>? _toJson(Object? model) => switch (model) {
|
||||
VideoModel m => m.toJson(),
|
||||
CartoonMediaInfo m => m.toJson(),
|
||||
CollectionModel m => m.toJson(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// 反序列化只能按 [T] 分派(拿不到实例)。**必须用 T == 类型比较**:
|
||||
/// 靠 T.toString() 对类名在 release 混淆后必然失配,表现是列表静默空白
|
||||
static T? _fromJson<T>(String raw) {
|
||||
final json = Map<String, dynamic>.from(jsonDecode(raw));
|
||||
if (T == VideoModel) return VideoModel.fromJson(json) as T;
|
||||
if (T == CartoonMediaInfo) return CartoonMediaInfo.fromJson(json) as T;
|
||||
if (T == CollectionModel) return CollectionModel.fromJson(json) as T;
|
||||
return null;
|
||||
}
|
||||
|
||||
//搜索历史(按时间倒序)
|
||||
static Future<List<String>> searchHistories() =>
|
||||
SearchHistoryStore().fetchAll();
|
||||
|
||||
//新增一条搜索历史(已存在则去重提到最前,超上限自动删最旧)
|
||||
static Future<void> addSearch(String keyword) =>
|
||||
SearchHistoryStore().save(keyword);
|
||||
|
||||
//清空搜索历史
|
||||
static Future<void> clearSearch() => SearchHistoryStore().clean();
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_https_gpl/return_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' hide Image;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_native_image/flutter_native_image.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:image_gallery_saver/image_gallery_saver.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:zxing2/qrcode.dart';
|
||||
|
||||
class ImageUtil {
|
||||
/// 从图片文件解析二维码(纯 Dart zxing,替代 ML Kit)
|
||||
/// iOS 相册多为 HEIC,image 包不支持→decodeImage 返回 null,先用原生统一转 jpg
|
||||
/// (顺带按 EXIF 摆正方向);转换走主 isolate(插件不能在 compute 里调),解码再丢 isolate
|
||||
static Future<String?> decodeQr(String path) async {
|
||||
var decodePath = path;
|
||||
try {
|
||||
final converted = await FlutterNativeImage.compressImage(path,
|
||||
percentage: 100, quality: 100);
|
||||
decodePath = converted.path;
|
||||
} catch (_) {
|
||||
// 转换失败就拿原图碰运气(本就是 jpg/png 时不影响)
|
||||
}
|
||||
return compute(_decodeQrSync, decodePath);
|
||||
}
|
||||
|
||||
/// isolate 解码入口(必须是 top-level/static 才能传给 compute)
|
||||
static String? _decodeQrSync(String path) {
|
||||
try {
|
||||
final image = img.decodeImage(File(path).readAsBytesSync());
|
||||
if (image == null) return null;
|
||||
final source = RGBLuminanceSource(
|
||||
image.width,
|
||||
image.height,
|
||||
image
|
||||
.convert(numChannels: 4)
|
||||
.getBytes(order: img.ChannelOrder.rgba)
|
||||
.buffer
|
||||
.asInt32List(),
|
||||
);
|
||||
final bitmap = BinaryBitmap(HybridBinarizer(source));
|
||||
// tryHarder:相册照片有边框/轻微旋转/噪点,放开更费时但识别率更高
|
||||
final hints = DecodeHints()..put(DecodeHintType.tryHarder);
|
||||
return QRCodeReader().decode(bitmap, hints: hints).text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取 [boundaryKey] 对应的 RepaintBoundary 存相册
|
||||
/// 注:boundary 必须完整渲染在屏幕内,被滚动容器裁剪会截不全甚至失败
|
||||
static Future<bool> saveWidgetToAlbum(GlobalKey boundaryKey) async {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
final boundary = boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) return false;
|
||||
|
||||
Image? image;
|
||||
try {
|
||||
image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final pngBytes = await image.toByteData(format: ImageByteFormat.png);
|
||||
if (pngBytes == null) return false;
|
||||
return await savePngToAlbum(pngBytes.buffer.asUint8List());
|
||||
} catch (e) {
|
||||
debugLog('saveWidgetToAlbum 截图失败', e);
|
||||
return false;
|
||||
} finally {
|
||||
// ui.Image 持有 native 内存,必须手动释放,否则每次截图都泄漏
|
||||
image?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存图片到相册(自动识别动图)
|
||||
///
|
||||
/// 动图转成 mp4 再存,另外几条路都堵死了:
|
||||
/// - 不能走 saveImage:Android 是 BitmapFactory、iOS 是 UIImage,都只取第一帧
|
||||
/// - 不能原样存 webp:MediaStore 收得下 image/webp,但绝大多数相册 App
|
||||
/// (含 Google Photos)只把 animated webp 当静态图渲染,看着还是一帧
|
||||
/// - 不用 GIF:纯 Dart 编码是每帧重建量化器 + LZW,又慢又只有 256 色(发糊起色带)
|
||||
static Future<bool> saveImageToAlbum(Uint8List? bytes) async {
|
||||
if (bytes == null) return false;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final stamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final rawPath = '${tempDir.path}/anim_$stamp.rgba';
|
||||
// 解码+合成放 isolate,且直接把 RGBA 写盘——十几兆的像素数组不跨 isolate 拷回来
|
||||
final meta = await compute(_decodeToRaw, {'bytes': bytes, 'path': rawPath});
|
||||
if (meta == null) return savePngToAlbum(bytes); //静态图/解不动,走原路径
|
||||
|
||||
try {
|
||||
final mp4 = await _rawToMp4(rawPath, meta);
|
||||
if (mp4 != null) return _saveRawToAlbum(mp4, 'mp4');
|
||||
return savePngToAlbum(bytes); //ffmpeg 失败:退化成存首帧,日志里有原因
|
||||
} finally {
|
||||
final raw = File(rawPath);
|
||||
if (await raw.exists()) await raw.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// isolate 入口:动图 → 合成好的 RGBA 裸流写入 [path],返回宽高/帧数/帧间隔;静态图返回 null
|
||||
///
|
||||
/// 走 raw 而不是让 ffmpeg 直接 `-i xxx.webp`:FFmpeg 到 7.1 才支持解 animated WebP,
|
||||
/// 这个 fork 的版本不确定,赌不起。裸流写盘只是 memcpy,成本可以忽略。
|
||||
static Map<String, int>? _decodeToRaw(Map<String, dynamic> args) {
|
||||
const maxFrames = 24;
|
||||
const maxSide = 640; //x264 编码便宜,可以给到比 GIF 时期高的分辨率
|
||||
final bytes = args['bytes'] as Uint8List;
|
||||
try {
|
||||
final decoder = img.findDecoderForData(bytes);
|
||||
if (decoder == null) return null;
|
||||
decoder.startDecode(bytes); //只解头/块信息,不解像素,很便宜
|
||||
final total = decoder.numFrames();
|
||||
debugLog('saveToAlbum',
|
||||
'${decoder.runtimeType} frames=$total size=${bytes.length}');
|
||||
if (total <= 1) return null;
|
||||
|
||||
// 必须整体 decode:animated webp 的每帧只是个局部矩形,靠 dispose/blend 往画布上叠,
|
||||
// 逐帧 decodeFrame 拿到的是没合成的碎片(帧时长也只有 decode 里才写)
|
||||
final src = decoder.decode(bytes);
|
||||
if (src == null || !src.hasAnimation) return null;
|
||||
|
||||
final step = (src.numFrames / maxFrames).ceil();
|
||||
final longest = src.width > src.height ? src.width : src.height;
|
||||
final scale = longest > maxSide ? maxSide / longest : 1.0;
|
||||
//H.264 的 yuv420p 要求宽高都是偶数,这里直接对齐掉
|
||||
final outW = ((src.width * scale).round() ~/ 2) * 2;
|
||||
final outH = ((src.height * scale).round() ~/ 2) * 2;
|
||||
if (outW <= 0 || outH <= 0) return null;
|
||||
|
||||
final sink = File(args['path'] as String).openSync(mode: FileMode.write);
|
||||
var count = 0;
|
||||
var delay = 0;
|
||||
for (var i = 0; i < src.numFrames; i += step) {
|
||||
//frames[0] 就是 src 本身、挂着整条动画,copyResize 会连整条一起缩放,得先取单帧副本
|
||||
final f = i == 0
|
||||
? img.Image.from(src.frames[0], noAnimation: true)
|
||||
: src.frames[i];
|
||||
//必须指定 average:copyResize 默认是 nearest,降采样直接丢像素,出来全是锯齿
|
||||
final out = (f.width == outW && f.height == outH)
|
||||
? f
|
||||
: img.copyResize(f,
|
||||
width: outW,
|
||||
height: outH,
|
||||
interpolation: img.Interpolation.average);
|
||||
sink.writeFromSync(out.getBytes(order: img.ChannelOrder.rgba));
|
||||
if (delay == 0 && f.frameDuration > 0) delay = f.frameDuration;
|
||||
count++;
|
||||
}
|
||||
sink.closeSync();
|
||||
if (count < 2) return null;
|
||||
|
||||
debugLog('saveToAlbum',
|
||||
'raw ${outW}x$outH frames=$count step=$step delay=$delay');
|
||||
//抽帧后帧间隔要乘上 step,整体播放速度才不变;源没写时长就兜 80ms
|
||||
return {
|
||||
'w': outW,
|
||||
'h': outH,
|
||||
'n': count,
|
||||
'delay': (delay > 0 ? delay : 80) * step
|
||||
};
|
||||
} catch (e) {
|
||||
debugLog('saveToAlbum', 'decodeToRaw failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// RGBA 裸流 → H.264 mp4(x264 在 -gpl 变体里才有)
|
||||
static Future<Uint8List?> _rawToMp4(
|
||||
String rawPath, Map<String, int> meta) async {
|
||||
final outPath = rawPath.replaceFirst(RegExp(r'\.rgba$'), '.mp4');
|
||||
final fps = (1000 / (meta['delay'] ?? 80)).clamp(1.0, 60.0);
|
||||
final cmd =
|
||||
'-y -f rawvideo -pixel_format rgba -video_size ${meta['w']}x${meta['h']} '
|
||||
'-framerate ${fps.toStringAsFixed(2)} -i "$rawPath" '
|
||||
'-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart "$outPath"';
|
||||
final out = File(outPath);
|
||||
try {
|
||||
final session = await FFmpegKit.execute(cmd);
|
||||
if (!ReturnCode.isSuccess(await session.getReturnCode())) {
|
||||
debugLog('saveToAlbum',
|
||||
'ffmpeg failed: ${await session.getAllLogsAsString()}');
|
||||
return null;
|
||||
}
|
||||
return await out.readAsBytes();
|
||||
} catch (e) {
|
||||
debugLog('saveToAlbum', 'rawToMp4 failed: $e');
|
||||
return null;
|
||||
} finally {
|
||||
if (await out.exists()) await out.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按原文件字节写进相册(不重编码)。[ext] 决定 MIME:mp4 落视频集合,图片落图片集合
|
||||
static Future<bool> _saveRawToAlbum(Uint8List bytes, String ext) async {
|
||||
if (!await requestAlbumPermission()) {
|
||||
showToast("请先开启相册权限");
|
||||
return false;
|
||||
}
|
||||
|
||||
final fileName = 'hgdj_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/$fileName');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
try {
|
||||
// iOS 插件对图片和视频的路径要求正好相反,传错就存不进去:
|
||||
// - 视频走 UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(path) 判定,只吃裸文件路径;
|
||||
// 再用 isReturnPathOfIOS:false 的 UISaveVideoAtPathToSavedPhotosAlbum,回调稳定
|
||||
// - 图片必须 isReturnPathOfIOS:true 才走 PHAsset 原文件写入(false 分支是 UIImage,动图丢帧),
|
||||
// 而那条分支用 URL(string:) 解析,裸路径没 scheme 会被 PhotoKit 拒,得传 file:// 形式
|
||||
final isVideo = ext == 'mp4';
|
||||
final result = await ImageGallerySaver.saveFile(
|
||||
(Platform.isIOS && !isVideo)
|
||||
? Uri.file(file.path).toString()
|
||||
: file.path,
|
||||
name: fileName,
|
||||
isReturnPathOfIOS: !isVideo,
|
||||
).timeout(const Duration(seconds: 20),
|
||||
onTimeout: () => null); //插件在拿不到 fullSizeImageURL 时不回调,兜底防卡死
|
||||
return result is Map && result["isSuccess"] == true;
|
||||
} catch (e) {
|
||||
debugLog('saveRawToAlbum 写入相册失败', e);
|
||||
return false;
|
||||
} finally {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 png 数据到相册(静态图/截图用;动图请走 [saveImageToAlbum])
|
||||
static Future<bool> savePngToAlbum(Uint8List? pngBytes) async {
|
||||
if (pngBytes == null) return false;
|
||||
|
||||
final hasPermission = await requestAlbumPermission();
|
||||
if (!hasPermission) {
|
||||
showToast("请先开启相册权限");
|
||||
return false;
|
||||
}
|
||||
|
||||
final fileName = 'hgdj_${DateTime.now().millisecondsSinceEpoch}.png';
|
||||
dynamic result;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/$fileName');
|
||||
await file.writeAsBytes(pngBytes, flush: true);
|
||||
try {
|
||||
result = await ImageGallerySaver.saveFile(file.path, name: fileName);
|
||||
} catch (e) {
|
||||
debugLog('savePngToAlbum 写入相册失败', e);
|
||||
return false;
|
||||
} finally {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = await ImageGallerySaver.saveImage(
|
||||
pngBytes,
|
||||
quality: 100,
|
||||
name: fileName,
|
||||
);
|
||||
}
|
||||
|
||||
return result is Map && result["isSuccess"] == true;
|
||||
}
|
||||
|
||||
/// 相册写入权限(存视频也走这里,见 VideoSaveUtil)
|
||||
static Future<bool> requestAlbumPermission() async {
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
final addOnlyStatus = await Permission.photosAddOnly.request();
|
||||
if (addOnlyStatus.isGranted || addOnlyStatus.isLimited) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final status = await Permission.photos.request();
|
||||
return status.isGranted || status.isLimited;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final sdkInt = deviceInfo.version.sdkInt;
|
||||
// Android 10+(API 29) 通过 MediaStore 保存,不需要额外权限
|
||||
if (sdkInt >= 29) return true;
|
||||
// Android 9 及以下需要存储写入权限
|
||||
final status = await Permission.storage.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:install_plugin/install_plugin.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../track_event_manager/device_service.dart';
|
||||
|
||||
//安装工具
|
||||
class InstallUtil {
|
||||
///安装android和ios的app
|
||||
installApp(String filePath) async {
|
||||
//判断权限是否已有
|
||||
if (await Permission.storage.request().isGranted) {
|
||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
deviceInfoPlugin.androidInfo.then((androidInfo) {
|
||||
InstallPlugin.installApk(filePath, appId: androidInfo.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
///安装apk
|
||||
static Future<bool> installApk(apkPath) async {
|
||||
bool exit = await File(apkPath).exists();
|
||||
if (!exit) {
|
||||
return Future.value(false);
|
||||
}
|
||||
String devId = DeviceInfoService.deviceId;
|
||||
Clipboard.setData(ClipboardData(text: "***$devId^^^"));
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
InstallPlugin.installApk(apkPath, appId: packageInfo.packageName).then((result) {}).catchError((error) {});
|
||||
return Future.value(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:mmkv/mmkv.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../tools_base/debug_log.dart';
|
||||
|
||||
/// 轻量级别存储模型(kv键值对)内部包含文件和内存缓存
|
||||
final lightKV = _LightModel();
|
||||
|
||||
class _LightModel {
|
||||
MMKV? mmkv;
|
||||
|
||||
/// MMKV 不可用时的兜底:iOS release 包 FFI 符号被 strip,MMKV 初始化抛 symbol not found,
|
||||
/// 会导致本地存储静默读写失败(如锁屏密码设置不上)。SharedPreferences 走 method channel,
|
||||
/// 不依赖 FFI,作为兜底保证纯本地功能可用。
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool get _useMmkv => mmkv != null;
|
||||
|
||||
Future config() async {
|
||||
await init();
|
||||
}
|
||||
|
||||
init() async {
|
||||
if (mmkv != null || _prefs != null) return; // 已初始化(任一可用),避免重复
|
||||
try {
|
||||
final rootDir = await MMKV.initialize();
|
||||
mmkv = MMKV.defaultMMKV(); // iOS release 若符号被 strip,这里会抛 symbol not found
|
||||
debugLog('MMKV for flutter with rootDir = $rootDir');
|
||||
} catch (e, s) {
|
||||
// 兜底:MMKV 初始化失败不能把 main()/启动流程带崩,否则会卡在启动页
|
||||
debugLog('MMKV init FAILED >>> $e\n$s');
|
||||
}
|
||||
// MMKV 起不来就启用 SharedPreferences 兜底
|
||||
if (mmkv == null) {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
debugLog('MMKV unavailable, fallback to SharedPreferences');
|
||||
} catch (e, s) {
|
||||
debugLog('SharedPreferences init FAILED >>> $e\n$s');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void remove(String key) async {
|
||||
await init();
|
||||
if (_useMmkv) {
|
||||
mmkv?.removeValue(key);
|
||||
} else {
|
||||
await _prefs?.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getString(String key) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return null;
|
||||
if (_useMmkv) return mmkv?.decodeString(key);
|
||||
return _prefs?.getString(key);
|
||||
}
|
||||
|
||||
Future<bool?> setString(String key, String? value,
|
||||
[bool genNewKey = true]) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return false;
|
||||
if (_useMmkv) return mmkv?.encodeString(key, value);
|
||||
if (value == null) return await _prefs?.remove(key);
|
||||
return await _prefs?.setString(key, value);
|
||||
}
|
||||
|
||||
Future<int?> getInt(String? key) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key!)) return null;
|
||||
if (_useMmkv) return mmkv?.decodeInt(key);
|
||||
return _prefs?.getInt(key);
|
||||
}
|
||||
|
||||
Future<bool?> setInt(String key, int value) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return false;
|
||||
if (_useMmkv) return mmkv?.encodeInt(key, value);
|
||||
return await _prefs?.setInt(key, value);
|
||||
}
|
||||
|
||||
Future<bool?> getBool(String? key, {bool defaultValue = false}) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key!)) return null;
|
||||
if (_useMmkv) return mmkv?.decodeBool(key, defaultValue: defaultValue);
|
||||
return _prefs?.getBool(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<bool?> setBool(String key, bool value) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return false;
|
||||
if (_useMmkv) return mmkv?.encodeBool(key, value);
|
||||
return await _prefs?.setBool(key, value);
|
||||
}
|
||||
|
||||
Future<List<String>?> getStringList(String key,
|
||||
[bool genNewKey = true]) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return null;
|
||||
if (_useMmkv) {
|
||||
var jsonS = mmkv?.decodeString(key);
|
||||
if (jsonS?.isNotEmpty == true) {
|
||||
return parseStringList(json.decode(jsonS!));
|
||||
}
|
||||
return <String>[];
|
||||
}
|
||||
return _prefs?.getStringList(key) ?? <String>[];
|
||||
}
|
||||
|
||||
///
|
||||
Future<bool?> setStringList(String key, List<String> list,
|
||||
[bool genNewKey = true]) async {
|
||||
await init();
|
||||
if (TextUtil.isEmpty(key)) return false;
|
||||
if (_useMmkv) {
|
||||
if (list.isEmpty) return mmkv?.encodeString(key, null);
|
||||
return mmkv?.encodeString(key, json.encode(list));
|
||||
}
|
||||
return await _prefs?.setStringList(key, list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hgdj/extension/extensions.dart';
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:hgdj/hj_utils/video_cache_manager.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../tools_base/cache/cancel_token_manager.dart';
|
||||
import '../tools_base/net/load_apk/dio_cli.dart';
|
||||
import 'file_util.dart';
|
||||
|
||||
const LOCAL_M3U8_FILTER = ".m3u8";
|
||||
const LOCAL_TS_FILTER = ".ts";
|
||||
const LOCAL_ALL_FILTER = ".*";
|
||||
const LOCAL_SERVER_PING_PATH = "/__ping__";
|
||||
|
||||
// localserver把这个请求给外部处理的回调
|
||||
typedef void CustomResponse(HttpRequest response);
|
||||
|
||||
const String local_server_tag = "cache-server";
|
||||
|
||||
/// 加密魔数头
|
||||
const encryptMagicNumber = [0x88, 0xA8, 0x30, 0xCB, 0x10, 0x76];
|
||||
|
||||
/// 加密密钥
|
||||
const ENCRYPT_KEY = 0xA3;
|
||||
|
||||
/// 是否是加密文件
|
||||
bool _isEncryptData(List<int> buf) {
|
||||
if (buf.empty() || buf.length < encryptMagicNumber.length) {
|
||||
return false;
|
||||
}
|
||||
for (int iLoop = 0; iLoop < encryptMagicNumber.length; iLoop++) {
|
||||
if (buf[iLoop] != encryptMagicNumber[iLoop]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 打印控制
|
||||
csPrint(Object msg) {
|
||||
// l.i(local_server_tag, "$msg", saveFile: false);
|
||||
}
|
||||
|
||||
/// headers 的异步构造函数
|
||||
typedef HeadersBuilder = Future<Map<String, String>> Function(Uri uri);
|
||||
|
||||
/// 是否加入二级缓存
|
||||
typedef JoinSubCache = bool Function(String specialCharacters);
|
||||
|
||||
/// 单个请求拦截单元
|
||||
class ReqFilter {
|
||||
/// 要拦截的正则表达,这里是以.m3u8 和 .ts 结尾的请求
|
||||
final String reg;
|
||||
|
||||
final String schema; //http, https
|
||||
final String host; // www.qiNiu.com
|
||||
final int? port; // null or 12345
|
||||
final String pathPrefix; //m3u8 是api/app/vid/m3u8
|
||||
final HeadersBuilder? headersBuilder;
|
||||
ReqFilter(this.reg, this.schema, this.host,
|
||||
{this.port, this.pathPrefix = "", this.headersBuilder})
|
||||
: assert(TextUtil.isNotEmpty(reg)),
|
||||
assert(TextUtil.isNotEmpty(host));
|
||||
|
||||
String toString() => Uri(scheme: schema, host: host, port: port).toString();
|
||||
}
|
||||
|
||||
/// localServer 第三版 功能
|
||||
/// 1,删除了以前依赖的flutter_cache_manager和文件锁,加快访问和减少维护成本;
|
||||
/// 2,增加了网络请求重复的检测;
|
||||
/// 3,增加了网络下载速度的功能;
|
||||
/// note-this: 不要随意修改,修改之前先问下我
|
||||
///
|
||||
/// localServer 第四版 功能
|
||||
/// 1,用cacheManager的请求接口盒请求去除重复来代替我们自己的;
|
||||
/// 2,减少网络请求错误;
|
||||
/// 3, 修复了一些bug;
|
||||
/// 4,去掉了以前的下载速度;
|
||||
/// 另外后期;1,可能把tasklist里面的cancel去掉;2,添加下载速度 okay
|
||||
/// note-this: 不要随意修改,修改之前先问下我
|
||||
///
|
||||
/// localServer 第五版 功能
|
||||
/// 实时返回响应数据
|
||||
/// localServer 第六版 功能
|
||||
/// 支持预缓存isPreCache
|
||||
/// localServer 第七版 功能
|
||||
/// 兼容色中色的localserver请求
|
||||
/// localServer 第八版 功能
|
||||
/// 支持任意文件任意请求异或加密,解密
|
||||
class CacheServer {
|
||||
static CacheServer? _instance;
|
||||
int serverPort = 14587;
|
||||
final _dio = createDio();
|
||||
|
||||
/// ts 流强制经过localserver
|
||||
/// 一些ts流带了域名,会直接访问;不经过localserver缓存
|
||||
bool forceThroghLocalServer = true;
|
||||
|
||||
/// 当前下载速度
|
||||
int _nowVideoSpeed = 0;
|
||||
|
||||
/// 获取当前的下载速度
|
||||
int get getVideoSpeed => _nowVideoSpeed;
|
||||
// 下载速度控制器
|
||||
//final BehaviorSubject<int> _speedController = BehaviorSubject<int>();
|
||||
// final PublishSubject<int> _speedController = PublishSubject<int>();
|
||||
// final StreamController<int> _speedController = StreamController.broadcast();
|
||||
// 下载速度的流
|
||||
//Stream<int> get onVideoSpeedUpdate => _speedController.asBroadcastStream();
|
||||
|
||||
// 失败m3u8 列表
|
||||
List<String> failedM3u8List = [];
|
||||
|
||||
/// localServer 闲时回调,应该返回一个remotePath
|
||||
AsyncValueGetter<String>? onLocalServerIdel;
|
||||
|
||||
HttpServer? _server;
|
||||
// 主缓存
|
||||
BaseCacheManager _cacheManager;
|
||||
// 二级缓存
|
||||
BaseCacheManager? _subCacheManager;
|
||||
|
||||
bool _openSubManager = true;
|
||||
|
||||
// 处理加密和解密的的函数
|
||||
Map<String, CustomResponse> _customResponse = {};
|
||||
|
||||
/// 启动Competer,避免重复启动
|
||||
Completer? _startCompleter;
|
||||
|
||||
// 是否允许加入二级缓存
|
||||
JoinSubCache? onJoinSubCache;
|
||||
ValueChanged<dynamic>? onErr;
|
||||
|
||||
/// 请求拦截表
|
||||
Map<String, ReqFilter> _reqFilterMap = {};
|
||||
|
||||
List<String> cdnAddressLists = [];
|
||||
|
||||
String? selectLine;
|
||||
|
||||
factory CacheServer({
|
||||
BaseCacheManager? cacheManager,
|
||||
bool forceThroghLocalServer = true,
|
||||
bool openSubManager = false,
|
||||
}) {
|
||||
if (_instance == null) {
|
||||
cacheManager ??= VideoCacheManager();
|
||||
_instance = CacheServer._(
|
||||
cacheManager,
|
||||
forceThroghLocalServer: forceThroghLocalServer,
|
||||
openSubManager: openSubManager,
|
||||
);
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
void registerErrCallBack(ValueChanged onError) {
|
||||
this.onErr = onError;
|
||||
}
|
||||
|
||||
CacheServer._(
|
||||
BaseCacheManager cacheManager, {
|
||||
bool forceThroghLocalServer = true,
|
||||
bool openSubManager = false,
|
||||
}) : _cacheManager = cacheManager,
|
||||
this.forceThroghLocalServer = forceThroghLocalServer,
|
||||
this._openSubManager = openSubManager,
|
||||
_subCacheManager = null;
|
||||
|
||||
/// 启动服务器
|
||||
/// 有错返错,没错返null
|
||||
Future start() async {
|
||||
if (_startCompleter != null) {
|
||||
csPrint("already has a startComplete");
|
||||
return _startCompleter?.future;
|
||||
}
|
||||
|
||||
_startCompleter = Completer();
|
||||
csPrint("begin start localserver inner");
|
||||
_startInner(); // async
|
||||
return _startCompleter?.future;
|
||||
}
|
||||
|
||||
/// 内部启动
|
||||
/// [remoteCdnAndPathPrefix] http://cnd/app/vid/
|
||||
_startInner() async {
|
||||
// 先关闭原来的,再启动
|
||||
await stop(true);
|
||||
_server = await _bindAlways();
|
||||
csPrint("_startInner()...begin listen:$serverPort");
|
||||
_server?.listen(_onRequest,
|
||||
onDone: _onServerDone, onError: _onServerError, cancelOnError: true);
|
||||
|
||||
csPrint("_bind success, start listen address:$localServerUri");
|
||||
_startCompleter?.complete();
|
||||
_startCompleter = null;
|
||||
}
|
||||
|
||||
/// 一直启动绑定localServer只到成功
|
||||
Future<HttpServer> _bindAlways() async {
|
||||
while (true) {
|
||||
try {
|
||||
csPrint("_bindAlways()...serverPort:$serverPort");
|
||||
return await HttpServer.bind(InternetAddress.loopbackIPv4, serverPort);
|
||||
} catch (e) {
|
||||
//l.e(local_server_tag, "bind fail $serverPort, err $e");
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
serverPort++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务器停止
|
||||
/// [force] 强制停止,设置为true将会不等待请求,直接关闭链接
|
||||
Future stop([bool force = false]) async {
|
||||
if (_server != null) {
|
||||
try {
|
||||
csPrint("[LOCSERV] 关闭server");
|
||||
await _server?.close(force: true);
|
||||
} catch (e) {
|
||||
//l.e(local_server_tag, "[LOCSERV] 关闭server失败,可能是因为服务被系统杀掉了");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求转发过滤器
|
||||
/// 根据文件后缀转发
|
||||
/// [reg] 正则表达式fileExtension需要带上. ".m3u8" ".ts"
|
||||
void addReqFilter(String reg, String forwardUrl,
|
||||
{bool force = false, String pathPrefix = "", HeadersBuilder? hb}) {
|
||||
assert(reg.startsWith("."));
|
||||
if (TextUtil.isEmpty(forwardUrl)) {
|
||||
showToast("CDN地址为空");
|
||||
}
|
||||
final uri = Uri.tryParse(forwardUrl);
|
||||
if (uri == null) {
|
||||
//l.e(local_server_tag, "addReqFilter()...invalid url: $forwardUrl");
|
||||
return;
|
||||
}
|
||||
// 转发结构体
|
||||
final reqFilter = ReqFilter(reg, uri.scheme, uri.host,
|
||||
port: uri.port, pathPrefix: pathPrefix, headersBuilder: hb);
|
||||
if (force) {
|
||||
// 不同的转发域,对应的转发函数
|
||||
_reqFilterMap[reg] = reqFilter;
|
||||
} else {
|
||||
_reqFilterMap.putIfAbsent(reg, () => reqFilter);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加外部系统拦截调用,对于localserver一般是ttl
|
||||
void addCustomResponse(String path, CustomResponse response,
|
||||
{bool force = false}) {
|
||||
if (force) {
|
||||
_customResponse[path] = response;
|
||||
} else {
|
||||
_customResponse.putIfAbsent(path, () => response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取localserver的信息
|
||||
Uri get localServerUri =>
|
||||
Uri(scheme: "http", host: _server?.address.host, port: _server?.port);
|
||||
|
||||
/// localserver的请求拦截
|
||||
void _onRequest(HttpRequest request) {
|
||||
switch (request.method) {
|
||||
case "GET":
|
||||
_handleGet(request);
|
||||
break;
|
||||
default:
|
||||
// ���时只支持GET
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
request.response.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _onServerError(err) {
|
||||
//l.e(local_server_tag, "_onServerError err $err");
|
||||
}
|
||||
|
||||
void _onServerDone() {
|
||||
csPrint("Server Closed.");
|
||||
_server = null;
|
||||
}
|
||||
|
||||
/// 拦截请求
|
||||
Future _handleGet(HttpRequest localReq) async {
|
||||
//这里处理其他层发送过来的ping
|
||||
if (localReq.uri.path == LOCAL_SERVER_PING_PATH) {
|
||||
localReq.response.statusCode = HttpStatus.ok;
|
||||
localReq.response.close();
|
||||
return;
|
||||
}
|
||||
csPrint("Receive ${localReq.method} request ${localReq.uri}");
|
||||
// 处理ttl的加密
|
||||
final response = _customResponse[localReq.uri.path];
|
||||
if (response != null) {
|
||||
csPrint("Custom Response ${localReq.uri}");
|
||||
response.call(localReq);
|
||||
return;
|
||||
}
|
||||
|
||||
//存储文件的路径������������也是cache的Key
|
||||
String cacheKey = getCacheKey(localReq.uri.path);
|
||||
// FileInfo info = await _cacheManager.getFileFromCache(cachePath);
|
||||
|
||||
// //找到缓存,把文件中的数据当响应流返回
|
||||
// if (null != info && info.file.existsSync()) {
|
||||
// try {
|
||||
// csPrint("Cache Hit path:$cachePath localUri:${localReq.uri}");
|
||||
// await localReq.response.addStream(info.file.openRead());
|
||||
// localReq.response.close();
|
||||
// return;
|
||||
// } on FileSystemException catch (e) {
|
||||
// l.e(local_server_tag,
|
||||
// "Cache Hit But FileSystemException ${localReq.uri} exception: $e");
|
||||
// await _cacheManager.removeFile(cachePath);
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
//非文件的本地请求
|
||||
final dot = localReq.uri.path.lastIndexOf(".");
|
||||
if (dot < 0) {
|
||||
csPrint("_handleGet path no registry ${localReq.uri}");
|
||||
localReq.response.statusCode = HttpStatus.badRequest;
|
||||
localReq.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据文件扩展名字获取请求过滤
|
||||
final fileExtension = localReq.uri.path.substring(dot);
|
||||
var reqFilter =
|
||||
_reqFilterMap[fileExtension] ?? _reqFilterMap[LOCAL_ALL_FILTER];
|
||||
if (reqFilter == null) {
|
||||
//l.e(local_server_tag, "_handleGet path not find ${localReq.uri} reqFilter:$fileExtension");
|
||||
localReq.response.statusCode = HttpStatus.badRequest;
|
||||
localReq.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 是否是预缓存的请求
|
||||
bool isPreCache = false;
|
||||
Map<String, String> queryParameters = {};
|
||||
queryParameters.addAll(localReq.uri.queryParameters ?? {});
|
||||
if (queryParameters.containsKey("isPreCache") ?? false) {
|
||||
queryParameters.remove("isPreCache");
|
||||
isPreCache = true;
|
||||
}
|
||||
|
||||
//构建远程请求和请求的headers
|
||||
var remoteUri = localReq.uri.replace(
|
||||
scheme: reqFilter.schema,
|
||||
host: reqFilter.host,
|
||||
port: reqFilter.port,
|
||||
queryParameters:
|
||||
fileExtension == LOCAL_TS_FILTER ? {} : queryParameters);
|
||||
var remoteUriStr = remoteUri.toString();
|
||||
if (remoteUriStr.endsWith("?")) {
|
||||
remoteUri = Uri.parse(remoteUriStr.replaceAll("?", ""));
|
||||
}
|
||||
|
||||
Map<String, String> localReqHeaders = {};
|
||||
localReq.headers.forEach((String name, List<String> values) {
|
||||
if (name == "host") return;
|
||||
if (values.empty()) return;
|
||||
// 暂时只取第一个
|
||||
localReqHeaders[name] = values[0];
|
||||
csPrint("request header:$name ${values[0]}");
|
||||
});
|
||||
if (reqFilter.headersBuilder != null) {
|
||||
localReqHeaders.addAll(await reqFilter.headersBuilder!.call(remoteUri));
|
||||
}
|
||||
// localReqHeaders.remove("range");
|
||||
csPrint("Cache Miss ${localReq.method} cachePath:$cacheKey => $remoteUri");
|
||||
|
||||
/// 获取远程文件,l里面调用了unawaited streamConroller close,不用担心Stream阻塞的问题
|
||||
// await for (var fileResp in _cacheManager.getFileStream(remoteUri.toString(),
|
||||
// headers: localReqHeaders)) {
|
||||
// if (fileResp is FileInfo) {}
|
||||
// }
|
||||
|
||||
Map<String, String> maps = {"CDN": selectLine ?? ""};
|
||||
|
||||
localReqHeaders.addAll(maps);
|
||||
|
||||
if (reqFilter.reg == LOCAL_ALL_FILTER
|
||||
// &&localReqHeaders.containsKey(HttpHeaders.rangeHeader)
|
||||
) {
|
||||
csPrint("_getAllBytes()...直接请求开始:${remoteUri.toString()}");
|
||||
var rangeStart = getRangeStart(localReqHeaders);
|
||||
try {
|
||||
// var oldToken = CancelTokenManager()
|
||||
// .remove(remoteUri.toString(), rangeStart.toString());
|
||||
// oldToken?.cancel('============>already have a same request :$rangeStart');
|
||||
// var newToken = CancelTokenManager()
|
||||
// .createToken(remoteUri.toString(), rangeStart.toString());
|
||||
|
||||
debugPrint("测试视频请求的header---- $localReqHeaders");
|
||||
|
||||
var resp = await _dio.get<ResponseBody>(remoteUri.toString(),
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: localReqHeaders,
|
||||
sendTimeout: const Duration(milliseconds: 85000),
|
||||
receiveTimeout: const Duration(milliseconds: 85000),
|
||||
),
|
||||
cancelToken: null);
|
||||
|
||||
localReq.response.statusCode = resp.statusCode ?? HttpStatus.badRequest;
|
||||
|
||||
if (localReq.response.statusCode == HttpStatus.ok ||
|
||||
localReq.response.statusCode == HttpStatus.accepted ||
|
||||
localReq.response.statusCode == HttpStatus.created ||
|
||||
localReq.response.statusCode == HttpStatus.partialContent) {
|
||||
var s = resp.data?.stream;
|
||||
|
||||
bool isEncrypt = false;
|
||||
var newS = s?.map<List<int>>((buf) {
|
||||
if (rangeStart <= 0) {
|
||||
isEncrypt = _isEncryptData(buf);
|
||||
if (isEncrypt) {
|
||||
return buf.sublist(encryptMagicNumber.length);
|
||||
} else {
|
||||
return buf;
|
||||
}
|
||||
} else {
|
||||
return buf;
|
||||
}
|
||||
}).map((buf) {
|
||||
return buf.map((it) => it ^ ENCRYPT_KEY).toList();
|
||||
});
|
||||
if (rangeStart <= 0) {
|
||||
resp.data?.headers.forEach((key, values) {
|
||||
if (HttpHeaders.contentLengthHeader == key) {
|
||||
var ct = int.parse(values[0] ?? "0");
|
||||
localReq.response.headers.add(key, ct > 0 ? (ct - 6) : ct);
|
||||
csPrint("resp header ct:$key ${ct > 0 ? (ct - 6) : ct}");
|
||||
} else {
|
||||
csPrint("resp header:$key $values");
|
||||
localReq.response.headers.add(key, values);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resp.data?.headers.forEach((key, values) {
|
||||
csPrint("resp header:$key $values");
|
||||
});
|
||||
}
|
||||
if (null != newS) {
|
||||
await localReq.response.addStream(newS);
|
||||
csPrint("===============>feed stream [success].......");
|
||||
} else {
|
||||
csPrint("===============>feed stream [failed].......");
|
||||
_requestErr(localReq);
|
||||
}
|
||||
}
|
||||
await localReq.response.close();
|
||||
} catch (e) {
|
||||
//l.e(local_server_tag, "getRangeFile()...error:$e");
|
||||
|
||||
//Logger().e("开始重新请求...........");
|
||||
|
||||
// _handleGet(localReq);
|
||||
|
||||
_requestErr(localReq);
|
||||
} finally {
|
||||
// CancelTokenManager()
|
||||
// .remove(remoteUri.toString(), rangeStart.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheManager = _getCacheManager(remoteUri.toString());
|
||||
var s = cacheManager
|
||||
.getFileStream(remoteUri.toString(),
|
||||
headers: localReqHeaders,
|
||||
withProgress: fileExtension != LOCAL_M3U8_FILTER)
|
||||
.handleError(
|
||||
(e) {
|
||||
if (!isPreCache) {
|
||||
this.onErr?.call(e);
|
||||
}
|
||||
// TODO 是否需要处理
|
||||
//l.e(local_server_tag, "getFileStream()...开始处理远端请求$remoteUri 错误");
|
||||
if (TextUtil.isNotEmpty(cacheKey) &&
|
||||
cacheKey.contains(LOCAL_M3U8_FILTER) &&
|
||||
!failedM3u8List.contains(cacheKey)) {
|
||||
//l.e(local_server_tag, "getFileStream()...添加到失败错误列表$cacheKey");
|
||||
failedM3u8List.add(cacheKey);
|
||||
}
|
||||
_requestErr(localReq);
|
||||
},
|
||||
|
||||
// test: (error) {
|
||||
// /// true 拦截任何错误
|
||||
// l.e(local_server_tag, "getFileStream()...远端请求$remoteUri 发生错误:$error");
|
||||
// _requestErr(localReq);
|
||||
// return true;
|
||||
// }
|
||||
).where((fileResp) {
|
||||
// 返回ture表示一直重试
|
||||
if (fileResp is FileInfo) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (fileExtension.contains(LOCAL_M3U8_FILTER)) {
|
||||
// 处理m3u8
|
||||
await _handleM3u8(remoteUri, localReq, s, cacheManager);
|
||||
} else if (fileExtension.contains(LOCAL_TS_FILTER)) {
|
||||
// 处理ts
|
||||
// await _handleTs(remoteUri, localReq, s, cacheManager, isPreCache);
|
||||
} else {
|
||||
// 处理所以文件
|
||||
//await _handleAllFile(remoteUri, localReq, s, cacheManager, isPreCache);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理M3u8
|
||||
_handleM3u8(Uri remoteUri, HttpRequest localReq, Stream<FileResponse> s,
|
||||
BaseCacheManager cacheManager) async {
|
||||
final sc = s.listen((f) {
|
||||
if (null == f) {
|
||||
//l.e(local_server_tag, "handleM3u8()...$remoteUri 来了个null���鬼东西");
|
||||
_requestErr(localReq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (f is FileInfo) {
|
||||
var len =
|
||||
localReq.response.headers.value(HttpHeaders.contentLengthHeader);
|
||||
if (null != len) {
|
||||
// 避���文件30天过期之后重新请求再次发送
|
||||
//l.e(local_server_tag, "handleM3u8()...$remoteUri already send resp to user now skip");
|
||||
return;
|
||||
}
|
||||
if (f.file.existsSync()) {
|
||||
var source = f.file.readAsStringSync();
|
||||
var sourceSize = source.length;
|
||||
|
||||
/// 强制ts请求通过local_server 主要是去除domin
|
||||
if (forceThroghLocalServer) {
|
||||
var lines = source.split("\n");
|
||||
var tsLine = lines.firstWhere(
|
||||
(it) => it.contains(LOCAL_TS_FILTER),
|
||||
);
|
||||
// csPrint("before source:$source");
|
||||
if (TextUtil.isNotEmpty(tsLine) && tsLine.startsWith("http")) {
|
||||
var domin = Uri.parse(tsLine ?? "").origin;
|
||||
if (TextUtil.isNotEmpty(domin)) {
|
||||
csPrint("need replace m3u8 inner host:$domin");
|
||||
source = source.replaceAll(domin, "");
|
||||
// csPrint("before source:$source");
|
||||
}
|
||||
}
|
||||
}
|
||||
var afterSize = source.length;
|
||||
|
||||
// localReq.response.headers
|
||||
// .add(HttpHeaders.contentLengthHeader, afterSize);
|
||||
// localReq.response.headers
|
||||
// .add(HttpHeaders.contentTypeHeader, "application/octet-stream");
|
||||
localReq.response.headers
|
||||
.add(HttpHeaders.contentLengthHeader, afterSize);
|
||||
localReq.response.headers
|
||||
.add(HttpHeaders.contentTypeHeader, "application/octet-stream");
|
||||
// localReq.response.contentLength = source.length;
|
||||
localReq.response.add(source.codeUnits);
|
||||
csPrint(
|
||||
"handleM3u8()...本地请求$remoteUri 完成:sourceSize:$sourceSize afterSize:$afterSize");
|
||||
localReq.response.close();
|
||||
} else {
|
||||
//l.e(local_server_tag, "handleM3u8()...本地请求$remoteUri 完成但是���件不存在");
|
||||
cacheManager.removeFile(f.originalUrl);
|
||||
_requestErr(localReq);
|
||||
}
|
||||
} else {
|
||||
// undo anything f is DownloadProgress
|
||||
}
|
||||
});
|
||||
sc.onError((e) {
|
||||
//l.e(local_server_tag, "handleM3u8()...发生了错误:$e");
|
||||
_requestErr(localReq, sc: sc);
|
||||
});
|
||||
s.timeout(Duration(seconds: 7), onTimeout: (sink) {
|
||||
//l.e(local_server_tag, "handleM3u8()...$remoteUri 超时了7秒没有反映,现在主动关闭请求");
|
||||
_requestErr(localReq, sc: sc);
|
||||
});
|
||||
}
|
||||
|
||||
/// 请求错误
|
||||
_requestErr(HttpRequest req, {StreamSubscription? sc}) async {
|
||||
if (null != req) {
|
||||
// if (null == req.response.statusCode) {
|
||||
try {
|
||||
req.response.statusCode = HttpStatus.badRequest;
|
||||
} catch (e) {
|
||||
//l.e(local_server_tag, "_requestErr()..." + req.response.toString());
|
||||
}
|
||||
// }
|
||||
await req.response.close();
|
||||
sc?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
BaseCacheManager _getCacheManager(String url) {
|
||||
// var sc = FileUtil.getNamePrefix(url);
|
||||
if (_openSubManager && (onJoinSubCache?.call(url) ?? false)) {
|
||||
// csPrint("从用户缓存manager");
|
||||
return _subCacheManager!;
|
||||
} else {
|
||||
// csPrint("从一般缓存manager");
|
||||
return _cacheManager;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消m3u8和m3u8关联的��有任���
|
||||
/// 这里取���的是远程请求,不是m3u8 preload里面的task
|
||||
void cancelM3u8(String localReqPath, [dynamic reason]) {
|
||||
csPrint("cancelM3u8 $localReqPath");
|
||||
if (TextUtil.isEmpty(localReqPath)) return;
|
||||
var name = FileUtil.getName(localReqPath);
|
||||
final prefix = FileUtil.getNamePrefix(localReqPath);
|
||||
final suffix = FileUtil.getNameSuffix(localReqPath);
|
||||
if (suffix != LOCAL_M3U8_FILTER) return;
|
||||
|
||||
//取消m3u8
|
||||
final m3u8CancelToken = CancelTokenManager().remove(name);
|
||||
if (null == m3u8CancelToken) return;
|
||||
csPrint("cancelM3u8()...cancel m3u8的下载:$localReqPath");
|
||||
m3u8CancelToken.cancel(reason);
|
||||
|
||||
//��消ts
|
||||
final List<String> removed = [];
|
||||
CancelTokenManager().peekList.forEach((it) {
|
||||
if (it.url.contains(prefix) && it.url.endsWith(LOCAL_TS_FILTER)) {
|
||||
removed.add(it.url);
|
||||
csPrint("cancelM3u8()...取消ts流的下载:$localReqPath");
|
||||
it.token.cancel(reason);
|
||||
}
|
||||
});
|
||||
for (final url in removed) {
|
||||
CancelTokenManager().remove(url);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Future<FileInfo?>?> getCacheFile(String remotePath) async {
|
||||
// String remoteUrl = getRemoteUrl(remotePath);
|
||||
if (TextUtil.isEmpty(remotePath)) return null;
|
||||
// l.i(local_server_tag, "getCacheFile()...闲时缓存策略预测需要缓��的url:$remoteUrl");
|
||||
var cacheManager = _getCacheManager(remotePath);
|
||||
var fileInfo = cacheManager.getFileFromMemory(getCacheKey(remotePath));
|
||||
fileInfo ??= (await cacheManager.getFileFromCache(getCacheKey(remotePath)))
|
||||
as Future<FileInfo?>;
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
String setSelectLine(String line) {
|
||||
selectLine = line;
|
||||
return selectLine!;
|
||||
}
|
||||
|
||||
/// m3u8远程地址转本地地址 远程程地址格式特定要求如下:url必须以.m3u8结尾
|
||||
/// [remotePath] 远程的播放路径 /xxx/xxx.m3u8
|
||||
/// 返回localhost 127.0.0.1的地址
|
||||
String? getLocalUrl(String remotePath, {Map<String, String>? queryParams}) {
|
||||
// return "http://192.168.1.142:8080/video/hls/prog_index.m3u8";
|
||||
// return "http://192.168.1.142:8080/video/hlss/index.m3u8";
|
||||
// return "http://192.168.1.142:8080/video/hlss/index_abs.m3u8";
|
||||
// return "http://192.168.1.142:8080/video/hls/prog_index_1.m3u8";
|
||||
// return "http://202.60.250.122:9001/video/hls/prog_index_1.m3u8";
|
||||
// return "https://fs.lhexm.com/sp/8a/67/lf/mg/605635d590d11f0679a058dbc013bd53.mp4";
|
||||
// remotePath = "sp/r1/8p/mt/if/970dd3185c924ff29c273917615c844a.m3u8";
|
||||
if (TextUtil.isEmpty(remotePath)) return null;
|
||||
var remoteUri = Uri.parse(remotePath); //修正后的绝对��径
|
||||
if (null == remoteUri) return null;
|
||||
final dot = remoteUri.path.lastIndexOf(".");
|
||||
if (dot <= 0 || dot >= remoteUri.path.length - 1) return null;
|
||||
final fileExtension = remoteUri.path.substring(dot);
|
||||
var reqFilter = _reqFilterMap[fileExtension];
|
||||
// if (null == reqFilter) return null;
|
||||
if (remotePath.startsWith("/")) {
|
||||
// 兼容绝对路径 /sp/vid/xxxx.m3u8
|
||||
remotePath = (reqFilter?.pathPrefix ?? "") + remotePath;
|
||||
} else if (remotePath.startsWith("http")) {
|
||||
// 兼容原始http和https
|
||||
// undo https://xxx/sp/vid/xxxx.m3u8
|
||||
} else {
|
||||
// 兼容相对���径 sp/vid/xxxx.m3u8
|
||||
remotePath = (reqFilter?.pathPrefix ?? "") + "/" + remotePath;
|
||||
}
|
||||
remoteUri = Uri.parse(remotePath);
|
||||
Map<String, String> query = {};
|
||||
query.addAll(remoteUri.queryParameters);
|
||||
query.addAll(queryParams ?? {});
|
||||
|
||||
// remoteUri.queryParameters.addAll(queryParams ?? {});
|
||||
// final localUri = localServerUri;
|
||||
if (!forceThroghLocalServer && remotePath.startsWith("http")) {
|
||||
return remoteUri.replace(queryParameters: query).toString();
|
||||
} else {
|
||||
//这里替换local127.0.0.1
|
||||
var localUrl = remoteUri
|
||||
.replace(
|
||||
scheme: localServerUri.scheme,
|
||||
host: localServerUri.host,
|
||||
port: localServerUri.port,
|
||||
queryParameters: query)
|
||||
.toString();
|
||||
return localUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取远程路径
|
||||
/// [remotePath] 远程的播放路径 /xxx/xxx.m3u8,或者本地请求
|
||||
String? getRemoteUrl(String remotePath) {
|
||||
if (TextUtil.isEmpty(remotePath)) return null;
|
||||
final dot = remotePath.lastIndexOf(".");
|
||||
if (dot <= 0 || dot >= remotePath.length - 1) return null;
|
||||
final fileExtension = remotePath.substring(dot);
|
||||
var reqFilter = _reqFilterMap[fileExtension];
|
||||
if (null == reqFilter) return null;
|
||||
if (remotePath.startsWith("/")) {
|
||||
// 兼容绝对路径 sp/vid/xxxx.m3u8
|
||||
remotePath = reqFilter.pathPrefix + remotePath;
|
||||
} else if (remotePath.startsWith("http")) {
|
||||
// undo https://xxx/sp/vid/xxxx.m3u8
|
||||
} else {
|
||||
// 兼容相对路径 sp/vid/xxxx.m3u8
|
||||
remotePath = reqFilter.pathPrefix + "/" + remotePath;
|
||||
}
|
||||
var uri = Uri.parse(remotePath); //修正后的绝对路径
|
||||
if (null == uri) return null;
|
||||
//这里替换远程
|
||||
final remoteUri = uri.replace(
|
||||
scheme: reqFilter.schema, host: reqFilter.host, port: reqFilter.port);
|
||||
return remoteUri.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// url/绝对路径和相对路径
|
||||
String getCacheKey(String url) {
|
||||
assert(null != url);
|
||||
return FileUtil.getName(url);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../tools_base/net/load_apk/dio_cli.dart';
|
||||
import 'local_server.dart';
|
||||
|
||||
/// 缓存服务的守护程序,主要是一个timer定时器不断重启;
|
||||
class LocalServerGuard {
|
||||
final CacheServer cacheServer;
|
||||
final _dio = DioCli();
|
||||
bool _running = false;
|
||||
bool _checking = false;
|
||||
|
||||
LocalServerGuard(this.cacheServer) : assert(cacheServer != null);
|
||||
|
||||
/// 运行localserver守护
|
||||
Future run() async {
|
||||
if (_running) return Future.value();
|
||||
_running = true;
|
||||
await cacheServer.start();
|
||||
Timer.periodic(const Duration(seconds: 5), (Timer t) async {
|
||||
if (_checking) {
|
||||
csPrint("local_server_gard is _checking,please wait...");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_checking = true;
|
||||
// csPrint("local_server_gard is called");
|
||||
Uri uri = cacheServer.localServerUri;
|
||||
if (uri != null) {
|
||||
uri = uri.replace(path: LOCAL_SERVER_PING_PATH);
|
||||
var options = Options(responseType: ResponseType.bytes);
|
||||
// l.i(local_server_tag, "begin ping local server");
|
||||
final response = await _dio.getBytes(uri.toString(), options: options);
|
||||
// l.i(local_server_tag, "end ping local server");
|
||||
if ((response.data?.statusCode ?? 0) > 0) return;
|
||||
}
|
||||
await cacheServer.start();
|
||||
} catch (e) {
|
||||
} finally {
|
||||
_checking = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
import '../track_event_manager/device_service.dart';
|
||||
import 'file_util.dart';
|
||||
|
||||
class MediaInfo {
|
||||
int? size;
|
||||
String? resolution;
|
||||
int? videoHeight;
|
||||
double? ratio;
|
||||
int? playTime;
|
||||
String? bitrate;
|
||||
String? mimeType;
|
||||
}
|
||||
|
||||
const _channel = MethodChannel(DeviceInfoService.naticeChannel);
|
||||
|
||||
/// 获取本地视频的元信息(高度/分辨率/比例/时长/比特率/大小/MIME)
|
||||
Future<MediaInfo?> getMediaInfo(String localPath) async {
|
||||
if (!FileUtil.isFileExist(localPath)) return null;
|
||||
final info = MediaInfo();
|
||||
info.videoHeight = int.tryParse(await _invokeChannel("getVideoHeight", localPath, "")) ?? 0;
|
||||
info.resolution = await _invokeChannel("getVideoResolution", localPath, "");
|
||||
info.ratio = double.tryParse(await _invokeChannel("getVideoRatio", localPath, "")) ?? 0;
|
||||
info.playTime = await _invokeChannel("getVideoDuration", localPath, 0);
|
||||
info.bitrate = await _invokeChannel("getVideoBitrate", localPath, "");
|
||||
info.size = FileUtil.getFileSize(localPath);
|
||||
info.mimeType = _getMimeType(localPath);
|
||||
return info;
|
||||
}
|
||||
|
||||
/// 统一调用原生方法,异常或类型不符时返回 fallback
|
||||
/// 注:不能只 catch PlatformException——原生 notImplemented / 没实现该 channel(iOS 就没实现这几个)
|
||||
/// 抛的是 MissingPluginException,不是 PlatformException,只兜前者等于没兜
|
||||
Future<T> _invokeChannel<T>(String method, String localPath, T fallback) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod(method, {'filePath': localPath});
|
||||
return result is T ? result : fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 MIME 类型,识别不到返回空串
|
||||
String _getMimeType(String localPath) => lookupMimeType(localPath) ?? '';
|
||||
@@ -0,0 +1,170 @@
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/loading/loading_alert_widget.dart';
|
||||
import '../../tools_base/net/base_resp_bean.dart';
|
||||
import '../../tools_base/net/net_code.dart';
|
||||
import '../api_service/buy_service.dart';
|
||||
|
||||
/// 下单商品类型,对应 /product/buy 的 productType 字段。
|
||||
/// 只收单品下单用到的类型;VIP 卡的 productType 由服务端随卡下发(新人卡 5、预售卡 21 等),
|
||||
/// 走 [PayManager.buyVip] 的 int
|
||||
enum ProductType {
|
||||
/// 1 单个作品:金币视频、黄游(SEED_LINK)、社区帖子
|
||||
media(1),
|
||||
|
||||
/// 19 整本/整部:漫画整本、动漫整部、短剧(买单集时靠 contentID 指定哪一集)
|
||||
mediaAll(19),
|
||||
|
||||
/// 25 漫画单集
|
||||
mediaChapter(25),
|
||||
|
||||
/// 101 群聊
|
||||
group(101);
|
||||
|
||||
const ProductType(this.value);
|
||||
|
||||
/// 传给接口的原始值
|
||||
final int value;
|
||||
}
|
||||
|
||||
/// 支付管理:统一下单流程。
|
||||
/// 内部包掉 loading(show → cancel)、余额不足(code 8000)跳充值,
|
||||
/// 外部只透传成功/失败回调,并可带来源页面标识 [source]。
|
||||
class PayManager {
|
||||
PayManager._();
|
||||
|
||||
static final PayManager _instance = PayManager._();
|
||||
|
||||
factory PayManager() => _instance;
|
||||
|
||||
/// 下单购买(普通商品:视频/漫画/帖子/群聊/短剧单集等)
|
||||
/// [productID] 商品ID [productType] 商品类型,见 [ProductType]
|
||||
/// [source] 来源页面标识
|
||||
/// [contentID] 子内容ID(短剧买单集时传这一集的 id)——短剧归因就靠它和 productID(剧 id),不另发字段
|
||||
/// [checkoutContextId] 付费墙下发的结算上下文
|
||||
/// [requestId] 幂等键,一笔业务拆成多次请求时复用同一个 id;不传则自动生成,见 BuyService._idempotent
|
||||
/// [jsonTransformation] 要拿模型而不是原始 json 时传,结果在 onSuccess 的 data.data 里
|
||||
/// [jumpWalletOnInsufficient] 余额不足(code 8000)是否自动跳充值页,默认 true;
|
||||
/// 已在充值页、或要就地弹金币支付的场景传 false(仅跳过跳转;失败提示由网络层统一弹)
|
||||
/// [onSuccess] 支付成功回调,各页面自行处理副作用(Get.back / 刷新钱包等)
|
||||
/// [onFailure] 业务失败回调,可选(失败提示已由网络层统一弹)
|
||||
Future<void> buy(
|
||||
String? productID,
|
||||
ProductType productType, {
|
||||
String? source,
|
||||
String? couponId,
|
||||
int? goldVideoCouponNum,
|
||||
String? serviceId,
|
||||
String? contentID,
|
||||
String? checkoutContextId,
|
||||
String? requestId,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
bool jumpWalletOnInsufficient = true,
|
||||
required void Function(BaseRespBean data) onSuccess,
|
||||
void Function(BaseRespBean? data)? onFailure,
|
||||
}) {
|
||||
return _request(
|
||||
() => BuyService.buyVideo(
|
||||
productID,
|
||||
productType.value,
|
||||
source: source,
|
||||
couponId: couponId,
|
||||
goldVideoCouponNum: goldVideoCouponNum,
|
||||
serviceId: serviceId,
|
||||
contentID: contentID,
|
||||
checkoutContextId: checkoutContextId,
|
||||
requestId: requestId,
|
||||
jsonTransformation: jsonTransformation,
|
||||
),
|
||||
jumpWalletOnInsufficient: jumpWalletOnInsufficient,
|
||||
//一次性商品「已经买过了」等价于买到手,见 _request
|
||||
repeatAsSuccess: true,
|
||||
onSuccess: onSuccess,
|
||||
onFailure: onFailure,
|
||||
);
|
||||
}
|
||||
|
||||
/// 金币购买 VIP(含预售尾款)
|
||||
/// [productType] VIP 卡类型由服务端随卡下发(新人卡 5、预售卡 21 等),不是 [ProductType],保持原样透传
|
||||
/// [finalPayStatus] 预售业务:true 付尾款 / false 付预定款;null 非预售
|
||||
/// [requestId] 幂等键,同 [buy]
|
||||
/// [experimentId]/[experimentVariant]/[sessionId] VIP 卡皮 A/B:与 /mine/topay 对齐回传
|
||||
/// [mediaId]/[contentId]/[checkoutContextId] 短剧付费墙开卡归因,同 [buy]
|
||||
/// 其余同 [buy]
|
||||
Future<void> buyVip(
|
||||
int? productType,
|
||||
String? productID,
|
||||
String? productName,
|
||||
int? discountedPrice, {
|
||||
String? source,
|
||||
String? couponId = "",
|
||||
bool? finalPayStatus,
|
||||
String? experimentId,
|
||||
String? experimentVariant,
|
||||
String? sessionId,
|
||||
String? mediaId,
|
||||
String? contentId,
|
||||
String? checkoutContextId,
|
||||
String? requestId,
|
||||
bool jumpWalletOnInsufficient = true,
|
||||
required void Function(BaseRespBean data) onSuccess,
|
||||
void Function(BaseRespBean? data)? onFailure,
|
||||
}) {
|
||||
return _request(
|
||||
() => BuyService.buyVip(
|
||||
productType,
|
||||
productID,
|
||||
productName,
|
||||
discountedPrice,
|
||||
source: source,
|
||||
couponId: couponId,
|
||||
finalPayStatus: finalPayStatus,
|
||||
experimentId: experimentId,
|
||||
experimentVariant: experimentVariant,
|
||||
sessionId: sessionId,
|
||||
mediaId: mediaId,
|
||||
contentId: contentId,
|
||||
checkoutContextId: checkoutContextId,
|
||||
requestId: requestId,
|
||||
),
|
||||
jumpWalletOnInsufficient: jumpWalletOnInsufficient,
|
||||
onSuccess: onSuccess,
|
||||
onFailure: onFailure,
|
||||
);
|
||||
}
|
||||
|
||||
/// 统一下单流程:loading + 余额不足(code 8000)跳充值页。
|
||||
/// 有响应的失败提示由 HttpResponseInterceptor 全局弹;网络无响应(超时/断网)不额外处理。
|
||||
/// [repeatAsSuccess] 8005 重复购买是否当成功。
|
||||
/// 只有一次性商品(视频/漫画/帖子/群聊/短剧单集)能开——「已经买过了」就是已拥有;
|
||||
/// VIP 不能开:会员是可叠加续费的,8005 不代表用户拿到了东西,
|
||||
/// 当成功会误报购买埋点、弹「购买成功」并把充值页关掉
|
||||
Future<void> _request(
|
||||
Future<BaseRespBean> Function() request, {
|
||||
bool jumpWalletOnInsufficient = true,
|
||||
bool repeatAsSuccess = false,
|
||||
required void Function(BaseRespBean data) onSuccess,
|
||||
void Function(BaseRespBean? data)? onFailure,
|
||||
}) async {
|
||||
LoadingAlertWidget.show();
|
||||
final BaseRespBean data;
|
||||
try {
|
||||
data = await request();
|
||||
} finally {
|
||||
// 先关 loading 再回调,避免 onSuccess 里的 Get.back 误弹到 loading 弹窗。
|
||||
// 走 finally 是因为请求前的签名(generateRequestOption→_sign)会抛,
|
||||
// 抛出去就没人关这层全屏 loading 了,用户只能杀进程
|
||||
LoadingAlertWidget.cancel();
|
||||
}
|
||||
|
||||
// 注意 8005 走进来时 data.data 是错误体不是订单结果,onSuccess 里取字段要判类型
|
||||
if (data.isSuccess || (repeatAsSuccess && data.code == Code.REPEAT_BUY)) {
|
||||
onSuccess(data);
|
||||
return;
|
||||
}
|
||||
// 余额不足:拦截器只弹提示不跳转,这里补跳充值页(已在充值页则传 false 跳过)
|
||||
if (data.code == Code.NOT_ENOUGH_MONEY && jumpWalletOnInsufficient) {
|
||||
pushToWalletPage(tabPosition: 1);
|
||||
}
|
||||
onFailure?.call(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_alert.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../tools_base/toast.dart';
|
||||
|
||||
class PermissionUtil {
|
||||
static Future<bool> checkPhotoPermission() async {
|
||||
final Permission permission;
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
permission = Permission.photos;
|
||||
} else {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
// 按 API 级别(sdkInt)判断:Android 13(API 33)起用照片权限,以下用存储权限。
|
||||
// 不能用 version.release(营销版本号字符串,ROM 可能写成 "12L" 等致解析跑偏)
|
||||
permission = androidInfo.version.sdkInt < 33
|
||||
? Permission.storage
|
||||
: Permission.photos;
|
||||
}
|
||||
return _ensure(permission, '需要相册权限才能继续');
|
||||
}
|
||||
|
||||
static Future<bool> checkPhotoAddOnlyPermission() async {
|
||||
final permission = (Platform.isIOS || Platform.isMacOS)
|
||||
? Permission.photosAddOnly
|
||||
: Permission.storage;
|
||||
return _ensure(permission, '需要相册权限才能继续');
|
||||
}
|
||||
|
||||
static Future<bool> checkCameraPermission() async {
|
||||
return _ensure(Permission.camera, '需要相机权限才能继续');
|
||||
}
|
||||
|
||||
/// 先查状态再决定,避免对已永久拒绝的权限再 request()(部分机型不弹框/不正常返回):
|
||||
/// 已授权(含受限相册)放行;永久拒绝/受限直接引导去设置;普通拒绝才弹系统授权框。
|
||||
static Future<bool> _ensure(Permission permission, String denyTip) async {
|
||||
final status = await permission.status;
|
||||
if (status.isGranted || status.isLimited) return true;
|
||||
// 注意:Android 上 status.isPermanentlyDenied 在「从未请求过」时也会为 true
|
||||
// (内部靠 shouldShowRequestRationale 判断,没问过和永久拒绝都返回 false,区分不开),
|
||||
// 会误判成永久拒绝、不弹框直接去设置。所以 Android 一律先 request(),
|
||||
// 由系统决定弹框还是立即返回结果;iOS/macOS 的 status 可靠,永久拒绝/受限直接去设置。
|
||||
// 前提:申请的权限必须已在 AndroidManifest 声明,否则 request() 会立即返回 denied 不弹框。
|
||||
if ((Platform.isIOS || Platform.isMacOS) &&
|
||||
(status.isPermanentlyDenied || status.isRestricted)) {
|
||||
_toSettings();
|
||||
return false;
|
||||
}
|
||||
// 普通拒绝/未询问(及 Android 全部情况):弹系统授权框,结果再统一处理
|
||||
return _handleStatus(await permission.request(), denyTip: denyTip);
|
||||
}
|
||||
|
||||
/// 处理 request() 后的结果:授权放行;永久拒绝引导去设置;普通拒绝弹 toast,
|
||||
/// 下次仍会弹系统授权框,避免误报"已禁止权限"
|
||||
static bool _handleStatus(PermissionStatus status,
|
||||
{String denyTip = '未授权,无法继续'}) {
|
||||
if (status.isGranted || status.isLimited) return true;
|
||||
if (status.isPermanentlyDenied || status.isRestricted) {
|
||||
_toSettings();
|
||||
} else {
|
||||
showToast(denyTip); //普通拒绝给个提示,避免点了没反应
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 引导去系统设置手动开启
|
||||
static void _toSettings() {
|
||||
CommonAlert.show(
|
||||
content: '您已禁止权限,需要去设置页面手动开启才能继续使用',
|
||||
cancelText: '重试',
|
||||
confirmText: '去设置',
|
||||
barrierDismissible: false, //必须点按钮,点遮罩关掉会以为处理过了
|
||||
).then((toSettings) {
|
||||
if (toSettings) openAppSettings();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/codec_support.dart';
|
||||
import 'package:hgdj/hj_utils/video_view_type.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/track_event_manager/device_service.dart';
|
||||
|
||||
/// 播放失败诊断(**测试包专用**)。
|
||||
///
|
||||
/// 播放失败时把现场信息拼成文本复制到剪贴板,让用户直接粘贴发回来——
|
||||
/// release 包里 debugLog/print 全不输出,这类"只有某台机器复现"的反馈没别的抓手。
|
||||
///
|
||||
/// 用法:失败分支调 [PlayDiagnose.report],开关见 [Config.playDiagnose]。
|
||||
class PlayDiagnose {
|
||||
PlayDiagnose._();
|
||||
|
||||
/// 最近几条失败记录:用户常连点几个视频都失败,一次把全部给出来才看得出是否同因
|
||||
static final List<String> _records = [];
|
||||
static const _maxRecords = 5;
|
||||
|
||||
/// 记录一次播放失败,并把累计记录复制到剪贴板。
|
||||
/// [scene] 场景(长视频/短视频/短剧)、[error] 原始异常、[url] 当次播放地址
|
||||
static Future<void> report({
|
||||
required String scene,
|
||||
required Object? error,
|
||||
String? url,
|
||||
String? videoId,
|
||||
String? videoTitle,
|
||||
bool? isH265,
|
||||
bool? forceH264,
|
||||
}) async {
|
||||
if (!Config.playDiagnose) return;
|
||||
|
||||
final net = await _netDesc();
|
||||
final buffer = StringBuffer()
|
||||
..writeln('【$scene】${DateTime.now()}')
|
||||
..writeln('视频: ${videoTitle ?? '-'} (id=${videoId ?? '-'})')
|
||||
..writeln('地址: ${url ?? '-'}')
|
||||
..writeln(
|
||||
'本次选流: ${isH265 == true ? 'H265' : 'H264'} 已回退264=${forceH264 ?? false}')
|
||||
..writeln('--- 编解码 ---')
|
||||
..writeln(
|
||||
'芯片硬解265=${CodecSupport.hwSupport} 本机已禁265=${CodecSupport.deviceDisabled} 最终用265=${CodecSupport.useH265}')
|
||||
..writeln(
|
||||
'渲染方式=${needPlatformView ? 'platformView' : 'textureView'} 已持久化=$platformViewPersisted')
|
||||
..writeln('判定为解码错误=${isDecoderError(error)}')
|
||||
..writeln('--- 环境 ---')
|
||||
..writeln(
|
||||
'机型: ${DeviceInfoService.brand} ${DeviceInfoService.model} 系统: ${DeviceInfoService.deviceOS} ${DeviceInfoService.systemVersion}')
|
||||
..writeln(
|
||||
'版本: ${Config.innerVersion} 用户: ${globalStore.meInfo?.uid ?? '-'} 设备: ${DeviceInfoService.deviceId}')
|
||||
..writeln('网络: $net 线路: ${httpManager.baseUrl}')
|
||||
..writeln('--- 错误原文 ---')
|
||||
..writeln('$error');
|
||||
|
||||
_records.add(buffer.toString());
|
||||
if (_records.length > _maxRecords) _records.removeAt(0);
|
||||
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: _records.join('\n${'=' * 30}\n')));
|
||||
showToast('失败信息已复制(${_records.length}条)\n请粘贴发给客服');
|
||||
}
|
||||
|
||||
static Future<String> _netDesc() async {
|
||||
try {
|
||||
final result = await Connectivity().checkConnectivity();
|
||||
return result.toString().replaceAll('ConnectivityResult.', '');
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// note this;
|
||||
/// 1,对于竖屏操作,这里我们只考虑宽适配,相当于操作16:9->4:3;
|
||||
/// 2,不考虑宽按照宽的比例缩放,高按照高的比例所犯(16:9->4:3伸缩严重);
|
||||
/// 3,布局中控件/字体中有固定大小的,统一乘以宽的缩放比例,剩余的用弹性布局填充;
|
||||
/// 4,使用之前,请确保_init()函数已经调用;
|
||||
/// 5,布局应该减少相对布局,使用居中布局,方便UI居中显示,和不同宽的两边留白;
|
||||
/// 6, 水平(宽)适配,每个页面应该支持尽量支持高可以滑动 SafeArea + SingleChildScrollView;
|
||||
/// 7,对于没有使用bottomItemBar的页面,如果页面底部有要交互的UI,请加上SafeArea包裹(适应全面屏幕)
|
||||
///
|
||||
///class 表示Screen和与之相关的SafeArea
|
||||
|
||||
var screen = _Screen();
|
||||
|
||||
class _Screen {
|
||||
// 全部走 GetX 实时取值,不缓存,免 context、免 configData 时机问题
|
||||
double get paddingLeft => Get.mediaQuery.padding.left;
|
||||
double get paddingRight => Get.mediaQuery.padding.right;
|
||||
// 一般为状态栏高度
|
||||
double get paddingTop => Get.mediaQuery.padding.top;
|
||||
// 一般为底部操作栏高度
|
||||
double get paddingBottom => Get.mediaQuery.padding.bottom;
|
||||
// 水平安全区域
|
||||
double get screenWidth => Get.width;
|
||||
// 垂直安全区域
|
||||
double get screenHeight => Get.height;
|
||||
// 设备像素比例
|
||||
double get devicePixelRatio => Get.mediaQuery.devicePixelRatio;
|
||||
}
|
||||
|
||||
extension IntWidget on int {
|
||||
Widget get sizeBoxH {
|
||||
return SizedBox(height: toDouble());
|
||||
}
|
||||
|
||||
Widget get sizeBoxW {
|
||||
return SizedBox(width: toDouble());
|
||||
}
|
||||
|
||||
Widget get line {
|
||||
return Container(
|
||||
height: toDouble(),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get lineV {
|
||||
return Container(
|
||||
height: toDouble(),
|
||||
width: 0.5,
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get sliverLine {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16),
|
||||
height: toDouble(),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get sliverSizeBoxH {
|
||||
return SliverToBoxAdapter(
|
||||
child: SizedBox(height: toDouble()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension DoubleWidget on double {
|
||||
Widget get sizeBoxH {
|
||||
return SizedBox(height: toDouble());
|
||||
}
|
||||
|
||||
Widget get sizeBoxW {
|
||||
return SizedBox(width: toDouble());
|
||||
}
|
||||
|
||||
Widget get line {
|
||||
return Container(
|
||||
height: toDouble(),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
);
|
||||
}
|
||||
|
||||
Widget get sliverLine {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16),
|
||||
height: toDouble(),
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MySliverDelegate extends SliverPersistentHeaderDelegate {
|
||||
final double? maxHeight;
|
||||
final double? minHeight;
|
||||
final Widget? child;
|
||||
final bool? forceRefresh; //是否需要强制刷新
|
||||
// double? shrinkOffset;
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
Widget? child,
|
||||
)? childBuildHandler;
|
||||
Function(double)? callTop;
|
||||
|
||||
MySliverDelegate({
|
||||
required this.maxHeight,
|
||||
required this.minHeight,
|
||||
this.child,
|
||||
this.childBuildHandler,
|
||||
this.forceRefresh = false,
|
||||
this.callTop,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
if (callTop != null) callTop!(shrinkOffset);
|
||||
if (childBuildHandler == null) {
|
||||
return SizedBox.expand(
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return childBuildHandler!(context, shrinkOffset, overlapsContent, child);
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxExtent => max(maxHeight!, minHeight!);
|
||||
|
||||
@override
|
||||
double get minExtent => minHeight!;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
|
||||
return (forceRefresh ?? false) ? true : maxHeight != oldDelegate.maxExtent || minHeight != oldDelegate.minExtent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'package:hgdj/config/config.dart';
|
||||
|
||||
/// lightKV 本地存储 key 集中管理
|
||||
class StoreKeys {
|
||||
// ===== 业务配置 =====
|
||||
/// 资源信息(图片、视频、官网等地址)
|
||||
static const String SOURCE_INFO = "sourceInfo";
|
||||
|
||||
/// 是否保存了二维码图片
|
||||
static const String HAVE_SAVE_QR_CODE = "haveSaveQrCode";
|
||||
|
||||
/// 最近存储的支付宝账号
|
||||
static const String LAST_A_ACCOUNT = "lastAliAcount";
|
||||
|
||||
// ===== 鉴权 / 用户 =====
|
||||
/// 网络 token
|
||||
static const String NET_TOKEN = '_key_net_token';
|
||||
|
||||
/// 用户密码本地锁
|
||||
static const String PASSWORD_LOCK = 'password_key';
|
||||
|
||||
// ===== 设备信息 =====
|
||||
/// 缓存的 User-Agent
|
||||
static const String UA_CACHE = '_key_user_agent';
|
||||
|
||||
/// 本机视频是否需用 platformView 渲染(海思等 TextureView 硬解失败后置位)
|
||||
static const String NEED_PLATFORM_VIEW = '_key_need_platform_view';
|
||||
|
||||
/// 本机是否禁用 H.265(芯片虚报硬解/hev1 封装等,播 265 失败后置位,永久回落 264)
|
||||
static const String H265_DISABLED = '_key_h265_disabled';
|
||||
|
||||
/// 设备 id
|
||||
static const String DEVICE_ID = 'device_id';
|
||||
|
||||
// ===== 网络 / 资源 =====
|
||||
/// 本地 ping 通的线路
|
||||
static const String DETECT_LINE = 'detectLineKey';
|
||||
|
||||
/// 本地广告列表(按 DEBUG 标志区分)
|
||||
static const String ADS_LIST = '_key_ads_list${Config.isDebug}';
|
||||
|
||||
/// 「首次免广告」是否已用掉(adverAbTestShowType == -1,只有第一次进 app 免)
|
||||
static const String AD_FREE_FIRST_USED = 'adFreeFirstUsed';
|
||||
|
||||
// ===== 业务记录 =====
|
||||
/// 视频缓存计数
|
||||
static const String MOVIE_CACHE_COUNT = 'AppMovieCacheCountKey';
|
||||
|
||||
/// 长视频缓存列表
|
||||
static const String MOVIE_CACHE_LIST = 'AppPlayMovieCacheVideoListKey';
|
||||
|
||||
/// 短视频缓存列表
|
||||
static const String SHORT_CACHE_LIST = 'AppPlayShortCacheVideoListKey';
|
||||
|
||||
/// 卡通缓存列表
|
||||
static const String CARTOON_CACHE_LIST = 'AppPlayCartoonCacheVideoListKey';
|
||||
|
||||
/// 短剧缓存列表(按集存)
|
||||
static const String DRAMA_CACHE_LIST = 'AppPlayDramaCacheVideoListKey';
|
||||
|
||||
/// 短剧下载授权的幂等键表:contentId -> X-Request-ID
|
||||
static const String DRAMA_DOWNLOAD_REQUEST_ID =
|
||||
'AppDramaDownloadRequestIdKey';
|
||||
|
||||
/// 免费观看视频列表
|
||||
static const String NEW_FREE_WATCH_VIDEOS = 'new_free_watch_videos';
|
||||
|
||||
// ===== 埋点会话 =====
|
||||
/// 当前 session id
|
||||
static const String TRACK_SESSION_SID = 'track_session_current_sid';
|
||||
|
||||
/// 最近一次事件时间戳
|
||||
static const String TRACK_SESSION_LAST_EVENT_TS =
|
||||
'track_session_last_event_ts';
|
||||
|
||||
/// 进入后台的时间戳
|
||||
static const String TRACK_SESSION_BACKGROUND_TS =
|
||||
'track_session_background_ts';
|
||||
|
||||
// ===== 首页更新红点 =====
|
||||
/// 「最新」Tab 最后查看的服务端更新时间(ISO8601)
|
||||
static const String HOME_LATEST_LAST_VIEW_AT = 'home_latest_last_view_at';
|
||||
|
||||
/// 「今日最新」最后查看的服务端更新时间(ISO8601)
|
||||
static const String HOME_TODAY_LATEST_LAST_VIEW_AT =
|
||||
'home_today_latest_last_view_at';
|
||||
|
||||
/// 首页模块最后查看时间前缀,完整 key = 前缀 + uid + '_' + moduleId
|
||||
static const String HOME_MODULE_LAST_VIEW_AT_PREFIX =
|
||||
'home_module_last_view_at_';
|
||||
|
||||
// ===== 短剧引导气泡 =====
|
||||
/// 已进过短剧 tab,「AI爽剧来袭」气泡永久不再出现
|
||||
static const String DRAMA_TIP_SHOWN = 'drama_tip_shown';
|
||||
|
||||
// ===== VIP 内容上新推送横幅 =====
|
||||
/// 已展示过的内容版本前缀,完整 key = 前缀 + uid(本地兜底去重,防回执未同步时重复弹)
|
||||
static const String VIP_PUSH_SHOWN_VERSION_PREFIX = 'vip_push_shown_version_';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 文本处理工具类
|
||||
class TextUtil {
|
||||
static bool isEmpty(String? text) {
|
||||
return (null == text || text.isEmpty);
|
||||
}
|
||||
|
||||
static bool isNotEmpty(String? text) {
|
||||
return (null != text && text.isNotEmpty);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析后端下发的字符串数组:非数组(null/空串/对象/数字)一律给空数组,
|
||||
/// 数组内的 null 丢掉,非字符串元素转字符串
|
||||
List<String> parseStringList(dynamic value) {
|
||||
if (value is! List) return [];
|
||||
return value.where((e) => e != null).map((e) => e.toString()).toList();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../config/config.dart';
|
||||
import '../hj_model/splash/domain_source_model.dart';
|
||||
|
||||
///启动页 /ping/domain 下发的当前平台版本信息,冷启动必刷新,不落磁盘
|
||||
CheckVersionInfo? _remoteVersion;
|
||||
|
||||
///启动页拿到配置后调一次,筛出当前平台那条存内存
|
||||
void saveVersion(List<CheckVersionInfo> versions) {
|
||||
final os = Platform.operatingSystem;
|
||||
_remoteVersion = versions.firstWhereOrNull((e) => e.platform?.toLowerCase() == os);
|
||||
}
|
||||
|
||||
///有新版本则返回版本信息,已是最新 / 没配置都返回 null;只读内存,不发请求
|
||||
CheckVersionInfo? checkUpdate() {
|
||||
final info = _remoteVersion;
|
||||
if (info == null) return null;
|
||||
return compareVersion(Config.innerVersion, info.verName ?? '') ? info : null;
|
||||
}
|
||||
|
||||
///比较版本号:newVersion 比 version 新则返回 true(需更新)
|
||||
bool compareVersion(String version, String newVersion) {
|
||||
List<String> localArr = version.split(".");
|
||||
List<String> newArr = newVersion.split(".");
|
||||
//逐位比较,最多比较前 3 段(major.minor.patch),按两者较短的段数取,避免越界
|
||||
int count = newArr.length < localArr.length ? newArr.length : localArr.length;
|
||||
if (count > 3) count = 3;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int newCode = int.tryParse(newArr[i]) ?? 0;
|
||||
int localCode = int.tryParse(localArr[i]) ?? 0;
|
||||
if (newCode > localCode) return true;
|
||||
if (newCode < localCode) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../tools_base/cache/dio_file_service.dart';
|
||||
|
||||
/// 视频缓存
|
||||
class VideoCacheManager extends CacheManager {
|
||||
static const key = "videoCache";
|
||||
|
||||
static VideoCacheManager? _instance;
|
||||
|
||||
factory VideoCacheManager() {
|
||||
_instance ??= VideoCacheManager._(fileService: DioFileService());
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
VideoCacheManager._({required FileService fileService})
|
||||
: super(Config(
|
||||
key,
|
||||
stalePeriod: const Duration(days: 100),
|
||||
maxNrOfCacheObjects: 200,
|
||||
fileService: fileService,
|
||||
));
|
||||
|
||||
/// 缓存存储路径
|
||||
Future<String?> getFilePath() async {
|
||||
final dir = await getCommonDir();
|
||||
return dir == null ? null : path.join(dir.path, key);
|
||||
}
|
||||
|
||||
/// 获取公用的dir目录
|
||||
static Future<Directory?> getCommonDir() {
|
||||
if (Platform.isIOS) return getTemporaryDirectory();
|
||||
if (Platform.isAndroid) return getExternalStorageDirectory();
|
||||
//桌面/其它平台用 app support 目录
|
||||
if (Platform.isFuchsia || Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
|
||||
return getApplicationSupportDirectory();
|
||||
}
|
||||
return getExternalStorageDirectory();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hgdj/hj_utils/light_model.dart';
|
||||
import 'package:hgdj/hj_utils/store_keys.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// 本机是否用 platformView 渲染(内存态,构造 controller 时同步读)。
|
||||
bool _needPlatformView = false;
|
||||
|
||||
/// 该决定是否已落本地(区分"已确认持久化"与"本次试探性切换、尚未确认")。
|
||||
bool _persisted = false;
|
||||
|
||||
/// 启动时调用一次(main 里):读本机持久化标记。
|
||||
///
|
||||
/// 背景:海思(Kirin)等芯片在默认 TextureView 渲染路径下,硬件解码器会抛
|
||||
/// `MediaCodecVideoRenderer error`(即便 ExoPlayer 报 format_supported=YES),
|
||||
/// 导致每个视频都"加载失败"。platformView(原生 SurfaceView 直渲)可绕开,
|
||||
/// 且**保留硬件解码、不引入软解**,性能无损。
|
||||
///
|
||||
/// 策略:**不做任何品牌/机型预判**。所有机型默认 textureView;一旦真出现芯片
|
||||
/// 解码/渲染错误,由 [switchToPlatformView] 内存切换并重试,确认能播后再由
|
||||
/// [confirmPlatformView] 落本地,之后该机所有视频与下次启动自动改用 platformView。
|
||||
/// 健康机型永远 textureView,零影响。
|
||||
Future<void> initVideoViewType() async {
|
||||
_needPlatformView =
|
||||
await lightKV.getBool(StoreKeys.NEED_PLATFORM_VIEW) ?? false;
|
||||
_persisted = _needPlatformView; // 本地已有 = 已确认,后续不再重复写
|
||||
}
|
||||
|
||||
/// 当前是否用 platformView 渲染(诊断用只读)。
|
||||
bool get needPlatformView => _needPlatformView;
|
||||
|
||||
/// platformView 标记是否已落本地(诊断用只读)。
|
||||
bool get platformViewPersisted => _persisted;
|
||||
|
||||
/// 视频渲染方式:本机已标记需要 → platformView,否则默认 textureView。
|
||||
VideoViewType resolveVideoViewType() {
|
||||
if (Platform.isAndroid && _needPlatformView) {
|
||||
return VideoViewType.platformView;
|
||||
}
|
||||
return VideoViewType.textureView;
|
||||
}
|
||||
|
||||
/// 解码/渲染失败时调用:**仅内存切换**到 platformView(不落本地),用于重试当前视频。
|
||||
/// 返回 true 表示本次发生切换(之前是 textureView)。落本地推迟到 platformView
|
||||
/// 重试**成功**后由 [confirmPlatformView] 完成,避免健康设备因个别坏视频被误标。
|
||||
bool switchToPlatformView() {
|
||||
if (_needPlatformView) return false; // 已是 platformView,无需切换/重试
|
||||
_needPlatformView = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 成功 initialize 后调用:若当前在 platformView 且尚未落本地,则确认并持久化。
|
||||
/// 健康机型(_needPlatformView=false)与已确认机型(_persisted=true)都是空操作。
|
||||
void confirmPlatformView() {
|
||||
if (_needPlatformView && !_persisted) {
|
||||
_persisted = true;
|
||||
lightKV.setBool(StoreKeys.NEED_PLATFORM_VIEW, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否解码器/渲染器类错误(值得切到 platformView),用于 catch 里判定。
|
||||
bool isDecoderError(Object? error) {
|
||||
final s = error.toString();
|
||||
return s.contains('MediaCodec') ||
|
||||
s.contains('DecoderInitialization') ||
|
||||
s.contains('VideoRenderer') ||
|
||||
s.contains('Decoder failed');
|
||||
}
|
||||
|
||||
// ==================== 播放器统一创建 ====================
|
||||
|
||||
/// 播放器统一创建入口。所有播放器都走这里,别再直接 `new VideoPlayerController`,
|
||||
/// 渲染方式(viewType) 等只需在此一处维护。
|
||||
///
|
||||
/// 防盗链备忘(暂未做,仅记想法):将来若要做 CDN 防盗链,在下面 network() 给
|
||||
/// networkUrl 补 `httpHeaders: {'Referer': xxx}` 即全端生效。Android 会自动带到
|
||||
/// m3u8 的 ts 切片;iOS 的 AVPlayer 对 HLS 切片不可靠,需本地代理另解。
|
||||
class PlayerFactory {
|
||||
/// 网络视频(m3u8/mp4)。
|
||||
static VideoPlayerController network(String? url,
|
||||
{VideoPlayerOptions? options}) {
|
||||
return VideoPlayerController.networkUrl(
|
||||
Uri.parse(url ?? ''),
|
||||
viewType: resolveVideoViewType(),
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
|
||||
/// 本地缓存视频。
|
||||
static VideoPlayerController file(String? path,
|
||||
{VideoPlayerOptions? options}) {
|
||||
return VideoPlayerController.file(
|
||||
File(path ?? ''),
|
||||
viewType: resolveVideoViewType(),
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
TextStyle textStyle(
|
||||
double fontSize,
|
||||
Color color,
|
||||
FontWeight fontWeight, {
|
||||
bool isNeedThrough = false,
|
||||
}) {
|
||||
return TextStyle(
|
||||
color: color, fontSize: fontSize.toDouble(), fontWeight: fontWeight, decoration: isNeedThrough ? TextDecoration.lineThrough : null);
|
||||
}
|
||||
Reference in New Issue
Block a user