import 'dart:convert'; import 'package:hgdj/config/address.dart'; import 'package:hgdj/hj_model/home/module_detail_model.dart'; import 'package:hgdj/hj_model/splash/ads_model.dart'; import 'package:hgdj/hj_model/user/user_info_model.dart'; import 'package:hgdj/hj_model/video_model.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:path/path.dart' as path; /// 广告总管:广告数据(内存+本地缓存)、免广告策略、列表插广告 class AdManager { static final AdManager _instance = AdManager._(); AdManager._(); factory AdManager() => _instance; List? _adsList; // 广告内存缓存,splash 启动早期由 preload 预热 List announceList = []; // 文字/图片公告,随 /ping/domain 下发 bool _adFreeAlways = false; // 首次进 app 免广告 DateTime? _adFreeUntil; // 限时免广告的到期时间,null 为不限时免广告 /// 预热广告内存缓存:内存命中直接返回,否则从磁盘读 Future preload() async { if (_adsList?.isNotEmpty == true) return; _adsList = _decodeAds(await lightKV.getString(StoreKeys.ADS_LIST)); } /// 按 [position] 同步取广告,走内存缓存 List adsByType(int? position) => _filterByPosition(_adsList, position); /// 按 [position] 异步取上次展示的广告,直接读磁盘(不走/不写内存缓存,splash 启动早期用) Future> lastAdsByType(int? position) async { if (position == null) return const []; return _filterByPosition( _decodeAds(await lightKV.getString(StoreKeys.ADS_LIST)), position); } /// 保存服务端下发的广告:cover 补全成完整地址后刷内存、写磁盘 Future saveAds(List? ads) async { for (final model in ads ?? const []) { final cover = model.cover; // 完整链接只取 path 拼当前图片域名,相对路径直接拼 final subPath = cover?.startsWith('http') == true ? Uri.tryParse(cover!)?.path : cover; model.cover = path.join(Address.baseImagePath ?? '', subPath); } _adsList = ads; try { final jsonStr = json.encode(ads); if (TextUtil.isEmpty(jsonStr)) return false; return lightKV.setString(StoreKeys.ADS_LIST, jsonStr); } catch (_) { return false; } } List? _decodeAds(String? jsonStr) { if (TextUtil.isEmpty(jsonStr)) return null; try { return (jsonDecode(jsonStr!) as List?) ?.map((o) => AdsInfoModel.fromMap(o)) .toList(); } catch (_) { return null; } } List _filterByPosition(List? all, int? position) { if (position == null || all == null || all.isEmpty) return const []; return all.where((it) => it.position == position).toList(); } /// 是否展示 ab 测广告,实时判定,限时免广告到点自动恢复展示 bool get showAbTestAd { if (_adFreeAlways) return false; return _adFreeUntil == null || DateTime.now().isAfter(_adFreeUntil!); } /// 按用户的广告 AB 类型刷新免广告策略 /// adverAbTestShowType: -1-首次进 app 免广告 0-所有广告都展示 其他N-注册后 N 分钟内免广告 /// 只能在登录成功后调用:AB 类型仅登录接口下发,拿 /mine/info 的结果刷新会把策略清空 Future refresh(UserInfoModel? userInfo) async { final type = userInfo?.adverAbTestShowType ?? 0; _adFreeAlways = false; _adFreeUntil = null; // 0 及其余取值:所有广告都展示 if (type == -1) { // 首次免广告只给第一次进 app:用掉就记本地,第二次启动起照常展示 final used = await lightKV.getBool(StoreKeys.AD_FREE_FIRST_USED) ?? false; _adFreeAlways = !used; if (!used) await lightKV.setBool(StoreKeys.AD_FREE_FIRST_USED, true); } else if (type > 0) { // 注册时刻起算 type 分钟:注册时间是固定的绝对时刻,重进 app 既不会续期也不会丢剩余时长 // createdAt 是 UTC 串,缺时区标记时补 Z 再解析,否则会被当成本地时间差出时区偏移 final raw = userInfo?.createdAt ?? ''; final registeredAt = DateTime.tryParse( raw.endsWith('Z') || raw.contains('+') ? raw : '${raw}Z') ?.toLocal(); _adFreeUntil = registeredAt?.add(Duration(minutes: type)); } } /// 专题列表插入广告:每隔 [adGap] 条普通内容插一条广告位 /// [modelArr] 原始列表(会被原地修改);[advList] 广告数据;[adGap] 间隔条数 void insertSectionAds(List modelArr, List advList, {int adGap = 5}) { if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return; int countGap = 0; // 距上一条广告已累计的普通内容数 modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告,避免重复插入 for (int i = 0; i < modelArr.length; i++) { AllSection model = modelArr[i]; if (countGap >= adGap) { // 已够间隔,在当前位置插一条广告(已是广告位则跳过) countGap = 0; if (!model.isAdsArr()) { AllSection adModel = AllSection(); adModel.adsInfoArr = advList; modelArr.insert(i, adModel); } } else { // 未够间隔:遇广告位重置计数,否则累加 countGap = model.isAdsArr() ? 0 : countGap + 1; } // 遍历到末尾且刚好满间隔,补一条广告收尾 if (countGap == adGap && i == modelArr.length - 1) { AllSection adModel = AllSection(); adModel.adsInfoArr = advList; modelArr.add(adModel); break; } } } /// 视频流插入「单条随机广告」:每隔 [adGap] 条插一条,广告按 adIndex 轮播取用 void insertRandomAds(List modelArr, List advList, {int adGap = 5}) { if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return; int countGap = 0; // 距上一条广告已累计的普通内容数 int adIndex = 0; // 当前取到第几条广告,与 advList 取模实现轮播 modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告 for (int i = 0; i < modelArr.length; i++) { VideoModel model = modelArr[i]; if (countGap >= adGap) { // 够间隔,插一条随机广告(已是随机广告则跳过) countGap = 0; if (!model.isRandomAd()) { VideoModel adModel = VideoModel(); adModel.randomAdsInfo = advList[adIndex % advList.length]; modelArr.insert(i, adModel); adIndex++; } } else { countGap = model.isAdsArr() ? 0 : countGap + 1; } // 末尾刚好满间隔,补一条收尾 if (countGap == adGap && i == modelArr.length - 1) { VideoModel adModel = VideoModel(); adModel.randomAdsInfo = advList[adIndex % advList.length]; modelArr.add(adModel); adIndex++; break; } } } /// 视频流插入「整组广告」:每隔 [adGap] 条插一条,广告位携带整个 advList(与 [insertRandomAds] 的单条随机不同) void insertGroupAds(List modelArr, List advList, {int adGap = 5}) { if (advList.isEmpty || modelArr.isEmpty || !showAbTestAd) return; int countGap = 0; // 距上一条广告已累计的普通内容数 modelArr.removeWhere((it) => it.isAdsArr()); // 先清掉旧广告 for (int i = 0; i < modelArr.length; i++) { VideoModel model = modelArr[i]; if (countGap >= adGap) { // 够间隔,插一条携带整组广告的广告位 countGap = 0; if (!model.isRandomAd()) { VideoModel adModel = VideoModel(); adModel.adsInfoArr = advList; modelArr.insert(i, adModel); } } else { countGap = model.isAdsArr() ? 0 : countGap + 1; } // 末尾刚好满间隔,补一条收尾 if (countGap == adGap && i == modelArr.length - 1) { VideoModel adModel = VideoModel(); adModel.adsInfoArr = advList; modelArr.add(adModel); break; } } } }