初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+669
View File
@@ -0,0 +1,669 @@
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import '../config/address.dart';
import '../tools_base/global_store/store.dart';
import 'cartoon_media_info.dart';
import 'drama_media_info.dart';
import 'media_content.dart';
/// 通用视频/帖子/图集/动漫/广告等多业务复合模型
/// 后端用同一个 model 承载多种业务,按 [newsType]、[mediaType]、[videoType] 区分具体形态
class VideoModel {
// ===== 标识 / 类型 =====
String? id; // 视频/帖子ID
String? vid; // 视频 vid
String? subid; // 动漫集数id
String? lsjId; // 老司机媒体资源ID
int? uid; // 发布者UID
String? newsType; // 帖子类型 SP/CSP/COVER_SP/COVER/AD_SP/AD_COVER
int? type; // 类型
int? videoType; // 0视频, 1 动漫
String? mediaType; // 媒体子类型:'image' 表示漫画(单位为"话"),其他视为视频/动漫(单位为"集")
int? showType; // 0-所有的人都可以看 1-奇数可看 2-偶数可看
//0 未审核 1通过 2审核失败 3视为免费 5、已下架
int? status;
String? reason; //审核拒绝理由
// ===== 内容 / 展示 =====
String? title; // 标题
String? name; // 动漫番号
String? desc; // 描述
String? content; // 正文内容
String? richText; //图文html
String? linkStr; // 富文本
String? cover; // 封面
String? coverThumb; // 封面缩略图
List<String>? seriesCover; // 多封面列表
List<TagsBean>? tags; // 标签列表
String? via; // 来源渠道
String? createdAt; // 创建时间
String? reviewAt; // 审核通过时间
bool? isTopping; // 是否置顶
bool? chosen; // 置精
Publisher? publisher; // 发布者
CommentBean? comment; // 热评
// ===== 播放源 =====
String? sourceURL; // 源视频地址(H.264,兜底)
String? h265Url; // H.265 播放地址(可空/空串表示无 265 资源)
String? chineseLink; // 中文字幕
String? codelessLink; // 无码链接 马赛克
String? previewURL; // 预览片段地址
int playContentStyle = 1; // 1:源视频,2:中文字幕,3:破解版(无码)
int? playTime; // 时长(秒)
double? ratio; //视频宽高比,没返回或者0,强制设置16/9
String? resolution; // 分辨率
int? size; // 文件大小
int? downloadAllow; // 0表示不允许下载 1表示VIP 2表示免费
// ===== 计价 / 权限 =====
int? coins; // 折扣价格
int? originCoins; // 原价
int? permission; //动漫权限 0:会员 1:金币购买 2:免费
///是否免费观看
bool? freeArea;
int? freeTime; // 免费试看时长(秒)
int? previewStart; //预览时间起始
/// 是否展示「免费试看」角标(需同时满足 ping 的 freeMark 总开关)
bool? showFreeTrialBadge;
/// 剩余试看次数
int? freeTrialRemaining;
/// 当前视频是否可用免费试看
bool? canUseFreeTrial;
VidStatus? vidStatus; // 当前用户对该视频的状态
// ===== 统计 =====
int? hot; // 热度
int? likeCount; // 点赞数
int? collectCount; // 收藏数
int? commentCount; // 评论数
int? playCount; // 播放数
int? pageViewCount; // 浏览数
int? videoCount; // 视频数
int? purchaseCount; // 购买数
// ===== 种子 =====
String? seedLink; // 种子链接
String? seedLinkUrl; // 种子下载链接
int? seedSize; //种子大小
int? seedPlayTime; // 种子时长(秒)
// ===== 动漫 / 漫画 =====
CartoonMediaInfo? mediaInfo; // 动漫信息
int? totalEpisode; // 动漫集数
int? updateStatus; //更新状态 0 默认(已完结) 1、更新中 2、已完结
// ===== 短剧 =====
// 只由 DramaMediaInfo.toVideoModel 组装,不参与 fromJson——短剧走自己的接口,不从视频列表里解析
DramaMediaInfo? dramaInfo; // 所属短剧(剧名/总集数/分集列表)
MediaContent? dramaEpisode; // 当前分集(权限/付费墙/播放地址以它为准)
int? episodeNo; // 当前是第几集
// ===== 广告 =====
AdsInfoModel? randomAdsInfo; // 随机广告
List<AdsInfoModel>? adsInfoArr; // 广告列表
/// 广告链接
String? linkUrl;
// ===== 客户端本地态(不参与接口解析)=====
// 短视频业务用到,二级短视频列表数据没有获取权限,需要通过详情接口获取,边播放边刷新数据。
VideoModel? detailVModel; // 短视频二级列表详情(无权限,边播边刷)
bool isLoadingDetail = false; // 详情是否加载中
String? localPath; // 下载到本地的路径
String? loadProgress; // 下载进度(百分比字符串)
String? isLoaderRunning; // 1 下载中 0 暂停
bool isSelected = false; // 列表编辑态是否勾选
String? videoTypeId; // 视频分类 id
String? videoTypeName; // 视频分类名
VideoModel({this.id});
VideoModel.fromJson(Map<dynamic, dynamic>? map) {
map ??= {};
showType = map['showType'];
richText = map['richText'];
previewStart = map['previewStart'];
// hot 后端可能下发 int / double / 字符串
final hotVal = map['hot'];
hot = hotVal is num ? hotVal.toInt() : int.tryParse('$hotVal');
if (map['permission'] is int) {
permission = map['permission'];
}
chineseLink = map['chineseLink'];
codelessLink = map["codelessLink"];
chosen = map['chosen'];
uid = map["uid"];
isTopping = map['isTopping'];
seedPlayTime = map['seedPlayTime'];
seedLinkUrl = map['seedLinkUrl'];
seedLink = map['seedLink'];
seedSize = map["seedSize"];
subid = map["subid"];
content = map["content"];
coins = map['coins'];
purchaseCount = map['purchaseCount'];
originCoins = map['originCoins'];
commentCount = map['commentCount'];
cover = map['cover'];
desc = map['desc'];
coverThumb = map['coverThumb'];
createdAt = map['createdAt'];
reviewAt = map['reviewAt'];
freeTime = map['freeTime'];
id = map['id'];
likeCount = map['likeCount'];
collectCount = map['collectCount'];
playCount = map['playCount'];
pageViewCount = map['pageViewCount'];
videoCount = map['videoCount'] ?? 0;
playTime = map['playTime'];
publisher = Publisher.fromMap(map['publisher']);
// 宽高比没返回或为 0 时兜底 16/9
final r = double.tryParse('${map['ratio'] ?? ''}') ?? 0;
ratio = r == 0 ? 16 / 9 : r;
resolution = map['resolution'];
size = map['size'];
type = map['type'];
sourceURL = map['sourceURL'];
h265Url = map['h265Url'];
previewURL = map['previewURL'];
status = map['status'];
freeArea = map['freeArea'];
showFreeTrialBadge = map['showFreeTrialBadge'];
freeTrialRemaining = map['freeTrialRemaining'];
canUseFreeTrial = map['canUseFreeTrial'];
name = map['name'];
// String 元素生成空 TagsBean,连同空名标签一起被 where 过滤掉
tags = (map['tags'] as List?)
?.map((o) => o is String ? TagsBean() : TagsBean.fromMap(o))
.where((e) => e.mergeName?.isNotEmpty == true) //过滤空字符串/空名标签
.toList();
title = map['title'];
via = map['via'];
comment =
map.containsKey("comment") ? CommentBean.fromMap(map['comment']) : null;
vidStatus = VidStatus.fromMap(map['vidStatus']);
if (map['seriesCover'] is List) {
seriesCover =
(map['seriesCover'] as List).map((e) => e.toString()).toList();
}
newsType = map['newsType'];
reason = map['reason'];
linkUrl = map['linkUrl'];
linkStr = map['linkStr'];
totalEpisode = map['totalEpisode'];
updateStatus = map['updateStatus'];
episodeNo = map['episodeNo'] ?? map['episodeNumber'];
mediaType = map['mediaType'];
vid = map['vid'];
videoType = map['videoType'];
downloadAllow = map['downloadAllow'];
videoTypeId = map['videoTypeId'];
videoTypeName = map['videoTypeName'];
lsjId = map['lsjId'];
// 刻意不解析 mediaInfovideo_detail_bottom_menu 用 `mediaInfo != null` 判定「漫画 / 长视频」,
// 一旦后端下发该字段就会把长视频误判成漫画(收藏走到 deleteBookshelf 分支)。
// 它只由客户端在 ACG 流程里显式赋值(见 video_logic 与 CartoonMediaInfo.toVideoModel),故两边都不参与序列化
}
/// 与 [VideoModel.fromJson] 严格对称:key 集合一致、嵌套对象也展开成 Map,
/// 存进本地(浏览历史 / 下载缓存记录)再读回来不丢字段。分组顺序与上方字段声明一致,便于比对。
/// 嵌套的那几个必须自己调 toJson()——只放对象时 jsonEncode 那条路能递归转,
/// 但 fromJson(toJson()) 这种内存直接往返会拿对象去喂 fromMap,抛 not a subtype
/// 两边都不参与序列化的:客户端本地态(playContentStyle / localPath / loadProgress /
/// isLoaderRunning / isSelected / detailVModel,由运行时查询回填)
/// 以及 mediaInfo(原因见 fromJson 末尾注释)。
Map<String, dynamic> toJson() => {
// 标识 / 类型
'id': id,
'vid': vid,
'subid': subid,
'lsjId': lsjId,
'uid': uid,
'newsType': newsType,
'type': type,
'videoType': videoType,
'mediaType': mediaType,
'showType': showType,
'status': status,
'reason': reason,
// 内容 / 展示
'title': title,
'name': name,
'desc': desc,
'content': content,
'richText': richText,
'linkStr': linkStr,
'cover': cover,
'coverThumb': coverThumb,
'seriesCover': seriesCover,
'tags': tags?.map((e) => e.toJson()).toList(),
'via': via,
'createdAt': createdAt,
'reviewAt': reviewAt,
'isTopping': isTopping,
'chosen': chosen,
'publisher': publisher?.toJson(),
'comment': comment?.toJson(),
// 播放源
'sourceURL': sourceURL,
'h265Url': h265Url,
'chineseLink': chineseLink,
'codelessLink': codelessLink,
'previewURL': previewURL,
'playTime': playTime,
'ratio': ratio,
'resolution': resolution,
'size': size,
'downloadAllow': downloadAllow,
// 计价 / 权限
'coins': coins,
'originCoins': originCoins,
'permission': permission,
'freeArea': freeArea,
'freeTime': freeTime,
'previewStart': previewStart,
'showFreeTrialBadge': showFreeTrialBadge,
'freeTrialRemaining': freeTrialRemaining,
'canUseFreeTrial': canUseFreeTrial,
'vidStatus': vidStatus?.toJson(),
// 统计
'hot': hot,
'likeCount': likeCount,
'collectCount': collectCount,
'commentCount': commentCount,
'playCount': playCount,
'pageViewCount': pageViewCount,
'videoCount': videoCount,
'purchaseCount': purchaseCount,
// 种子
'seedLink': seedLink,
'seedLinkUrl': seedLinkUrl,
'seedSize': seedSize,
'seedPlayTime': seedPlayTime,
// 动漫 / 漫画 / 短剧
'totalEpisode': totalEpisode,
'updateStatus': updateStatus,
//短剧缓存记录靠它显示「第N集」,不写回读盘后集数就丢了
'episodeNo': episodeNo,
// 广告
'linkUrl': linkUrl,
// 视频分类
'videoTypeId': videoTypeId,
'videoTypeName': videoTypeName,
};
// ===== 播放地址 =====
/// 拼 m3u8 完整播放链接:各播放源共用同一套 host / token / cdn 参数。
/// 后端偶尔直接下发完整链接(265、短剧下载授权地址),那种别再往前面拼一遍 host
String _m3u8(String? path) {
if (path != null &&
(path.startsWith('http://') || path.startsWith('https://')))
return path;
return "${Address.baseApiPath}/vid/h5/m3u8/$path?token=${Address.token}&c=${Address.cdnAddress}";
}
/// 源视频播放 m3u8 完整链接(H.264,兜底)
String get realVideoUrl => _m3u8(sourceURL);
/// 无码(破解版)m3u8 完整链接
String get realUncodeUrl => _m3u8(codelessLink);
/// 中文字幕版 m3u8 完整链接
String get realChineseUrl => _m3u8(chineseLink);
/// 预览片段 m3u8 完整链接
String get realPreviewUrl => _m3u8(previewURL);
/// H.265 播放 m3u8 完整链接。h265Url 已是完整 http 链接则直接用
String get realH265Url {
final u = h265Url ?? '';
if (u.isEmpty) return '';
return u.startsWith('http') ? u : _m3u8(u);
}
/// 详情页最终播放 URL(整合各分支,优先级从上到下):
/// - playContentStyle 2 且有中字链接 → 中字源
/// - playContentStyle 3 且有无码链接 → 无码源
/// - 命中预览权 [canPlayPreview] → 预览片段
/// - 普通源:[useH265] 且有 265 地址 → 265(省带宽),否则 264(兜底)
/// [useH265] 由调用方算好传入(设备硬解能力 + 本条是否已运行时回退),model 不依赖 CodecSupport
String realPlayUrl({required bool canPlayPreview, required bool useH265}) {
if (playContentStyle == 2 && chineseLink?.isNotEmpty == true)
return realChineseUrl;
if (playContentStyle == 3 && codelessLink?.isNotEmpty == true)
return realUncodeUrl;
if (canPlayPreview) return realPreviewUrl;
if (useH265 && realH265Url.isNotEmpty) return realH265Url;
return realVideoUrl;
}
// ===== 派生取值 =====
/// 展示用创建时间:createdAt 为空或后端占位值时退回审核通过时间
String? get realCreateAt {
if (createdAt?.contains("0001-01-01") == true ||
createdAt?.isNotEmpty != true) {
return reviewAt;
}
return createdAt;
}
/// 当前播放秒数是否处于免费试看区间 [previewStart, previewStart + freeTime]
bool isInFreeTime(int sec) {
final start = previewStart ?? 0;
final end = start + (freeTime ?? 0);
return sec >= start && sec < end;
}
bool get isDarkTag =>
tags?.any((e) => e.name?.contains("暗网") == true) ?? false;
String get allTags => tags?.map((e) => e.name).join(' ') ?? '';
/// 是否已点赞(vidStatus 为 null 时视为 false
bool get hasLiked => vidStatus?.hasLiked ?? false;
/// 是否已收藏(vidStatus 为 null 时视为 false
bool get hasCollected => vidStatus?.hasCollected ?? false;
/// 下载进度 0~1
double get progress => (double.tryParse(loadProgress ?? '') ?? 0) / 100;
/// 下载任务是否正在跑("2" 是已完成,不算跑)
bool get isDownloading => isLoaderRunning == "1";
/// 免费下载(downloadAllow 2):不校验 VIP / 次数,图标也换免费那套
bool get isFreeDownload => downloadAllow == 2;
String get seedSizeDesc => "${seedSize}Mb";
String get seedPlayTimeDesc {
final count = seedPlayTime ?? 0;
return count > 0 ? DateTimeUtil.formatHMS(count) : "";
}
String get seedRealLink {
if (seedLinkUrl?.isNotEmpty == true) {
var linkArr = seedLinkUrl?.split("[种子链接]:") ?? [];
if (linkArr.length == 2) {
return linkArr.last;
}
linkArr = seedLinkUrl?.split("[下载链接]:") ?? [];
if (linkArr.length == 2) {
return linkArr.last;
}
}
return seedLinkUrl ?? "";
}
bool isRandomAd() => randomAdsInfo != null;
bool isAdsArr() => adsInfoArr != null;
/// 是否需要金币购买(coins 或 originCoins 大于 0
bool isCoinVideo() => (coins ?? 0) > 0 || (originCoins ?? 0) > 0;
/// 实际售价:VIP 走折扣价,非 VIP 走原价
int? get realCoins => globalStore.isVIP ? coins : originCoins;
int videoCoin() => realCoins ?? 0;
//评论类型,传参数类型
String get commentType => 'video';
//集数状态描述
String get episodeNumberStatus => mediaInfo?.episodeNumberStatus ?? '';
/// 动漫/漫画的更新状态描述:updateStatus==1 显示"更新N集/话",其他状态一律"已完结"
String get updateDesc {
final unit = mediaType == 'image' ? '' : '';
if (updateStatus == 1) return '更新$totalEpisode$unit';
return '已完结';
}
}
/// 图集与黄游内容的解锁规则扩展
extension LockType on VideoModel {
/// 解锁样式(图集与黄游共用同一套规则):0-不需要解锁 1-金币解锁 2-会员解锁
int get lockType {
//1.免费
if (freeArea == true) return 0;
if (globalStore.isSuperUp) return 0;
//2.自己发布
if (globalStore.isMe(publisher?.uid)) return 0;
//3.金币解锁
if ((originCoins ?? 0) > 0) {
//是否购买
if (vidStatus?.hasPaid == true) return 0;
//是否免费
if (coins == 0) return 0;
return 1;
}
//vip解锁
if (globalStore.isRechargeVIP) return 0;
return 2;
}
}
/// 帖子流相关的判断与封面取值扩展(社区/个人主页等场景使用)
extension Post on VideoModel {
bool isVideo() {
return newsType == 'SP' ||
newsType == 'MOVIE' ||
newsType == 'SHORT' ||
newsType == 'COVER';
}
bool isSeedPost() {
return newsType == 'ADULT_GAME' ||
(seedLink?.isNotEmpty ?? false) ||
newsType == 'SEED_LINK';
}
}
/// 当前用户对该视频的状态:是否点赞/收藏/购买/已读等
class VidStatus {
bool? hasCollected;
bool? hasLiked;
bool? hasPaid;
int? todayPlayCnt;
int? todayRank;
bool? hasDisliked;
bool? hasPaidSeed;
bool? hasGraded;
bool? hasAddBookshelf;
bool? hasRead;
static VidStatus fromMap(Map<String, dynamic>? map) {
map ??= {};
VidStatus info = VidStatus();
info.hasCollected = map['hasCollected'];
info.hasLiked = map['hasLiked'];
info.hasPaid = map['hasPaid'];
info.todayPlayCnt = map['todayPlayCnt'];
info.todayRank = map['todayRank'];
info.hasDisliked = map['hasDisliked'];
info.hasPaidSeed = map['hasPaidSeed'];
info.hasGraded = map['hasGraded'];
info.hasAddBookshelf = map['hasAddBookshelf'];
info.hasRead = map['hasRead'];
return info;
}
Map<String, dynamic> toJson() => {
"hasDisliked": hasDisliked,
"hasCollected": hasCollected,
"hasLiked": hasLiked,
"hasPaid": hasPaid,
"todayPlayCnt": todayPlayCnt,
"todayRank": todayRank,
"hasPaidSeed": hasPaidSeed,
"hasGraded": hasGraded,
"hasAddBookshelf": hasAddBookshelf,
"hasRead": hasRead,
};
}
/// 标签 / 话题 / 分类公共模型(视频标签、帖子话题、ACG 分类共用)
class TagsBean {
String? coverImg;
String? description;
bool? hasCollected;
String? id;
String? name;
int? playCount;
int? type;
String? hotMark;
String? tagName;
int? videoCount;
int? collCount;
bool isSelected = false;
int? vidCount;
int? followCount;
/// 客户端本地态,故意不参与 fromMap / toJson:由跳转方设定,决定标签页拉哪类内容。
/// CommunityTagLogic 请求到新 tag 后会把它手动搬回来(见该 logic 的 onReady),别当僵尸字段删掉
String? newsType;
TagsBean({this.id, this.name});
//兼容name和tagname,后台返回不一致
String? get mergeName => tagName?.isNotEmpty == true ? tagName : name;
TagsBean.fromMap(Map<String, dynamic>? map) {
map ??= {};
vidCount = map['vidCount'];
followCount = map['followCount'];
hotMark = map['hotMark'];
tagName = (map['tagName'] as String?)?.trim();
videoCount = map['videoCount'];
coverImg = map['coverImg'];
description = map['description'];
hasCollected = map['hasCollected'];
id = map['id'];
name = (map['name'] as String?)?.trim();
type = map['type'];
playCount = map['playCount'];
collCount = map['collCount'];
}
Map<String, dynamic> toJson() => {
"coverImg": coverImg,
"description": description,
"hasCollected": hasCollected,
"id": id,
"name": name,
"playCount": playCount,
"tagName": tagName,
};
}
/// 视频/帖子发布者信息(用户基础资料 + VIP / 认证 / 活跃度等)
class Publisher {
int? age;
String? gender;
bool? hasFollowed;
String? name;
String? portrait;
int? uid;
int? vipLevel; //月卡 季卡 年卡
bool? isVip;
bool? superUser; //大v
int? activeValue; //活跃值
bool? officialCert; //官方认证
int? rechargeLevel; //vip等级 vip1-vip8
int? fans;
int? merchantUser; //认证商户
List<int> awards = [];
String? upTag;
int? totalWorks;
String? vipName;
Publisher();
Publisher.fromMap(Map<String, dynamic>? map) {
map ??= {};
if (map['awards'] is List) {
awards = [...(map['awards'] as List).map((o) => o)];
}
totalWorks = map['totalWorks'];
upTag = map['upTag'];
merchantUser = map['merchantUser'];
age = map['age'];
gender = map['gender'];
hasFollowed = map['hasFollowed'];
name = map['name'];
portrait = map['portrait'];
uid = map['uid'];
vipLevel = map['vipLevel'];
isVip = map['isVip'];
if (map['superUser'] is bool) {
superUser = map['superUser'];
} else if (map['superUser'] is int) {
superUser = map['superUser'] >= 1;
}
activeValue = map['activeValue'];
officialCert = map['officialCert'];
rechargeLevel = map['rechargeLevel'];
fans = map['fans'];
vipName = map['vipName'];
}
Map<String, dynamic> toJson() => {
"age": age,
"gender": gender,
"hasFollowed": hasFollowed,
"name": name,
"portrait": portrait,
"uid": uid,
"vipLevel": vipLevel,
"isVip": isVip,
"superUser": superUser,
"activeValue": activeValue,
"officialCert": officialCert,
"rechargeLevel": rechargeLevel,
};
}
/// 详情页热评(取一条最热评论附带在视频/帖子主体里展示)
class CommentBean {
int? uid;
String? name;
String? portrait;
String? cid;
String? content;
int? likeCount;
bool? isAuthor;
String? createdAt;
CommentBean.fromMap(Map<String, dynamic>? map) {
map ??= {};
uid = map['uid'];
name = map['name'];
portrait = map['portrait'];
cid = map['cid'];
content = map['content'];
likeCount = map['likeCount'];
isAuthor = map['isAuthor'];
createdAt = map['createdAt'];
}
Map<String, dynamic> toJson() => {
"uid": uid,
"name": name,
"portrait": portrait,
"cid": cid,
"content": content,
"likeCount": likeCount,
"isAuthor": isAuthor,
"createdAt": createdAt,
};
}