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? seriesCover; // 多封面列表 List? 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? 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? 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']; // 刻意不解析 mediaInfo:video_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 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? 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 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? 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 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 awards = []; String? upTag; int? totalWorks; String? vipName; Publisher(); Publisher.fromMap(Map? 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 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? 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 toJson() => { "uid": uid, "name": name, "portrait": portrait, "cid": cid, "content": content, "likeCount": likeCount, "isAuthor": isAuthor, "createdAt": createdAt, }; }