Files
huangguo_android/lib/hj_model/video_model.dart
T
2026-09-15 15:44:13 +07:00

670 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}