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 = []; /// 次数/已看列表变更版本号:列表角标监听它,会话内试看次数用尽即刷新,不必重拉列表。 final ValueNotifier revision = ValueNotifier(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 refresh() async { remain = await VidService.fetchWatchCount(); final saved = await lightKV.getStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS) ?? const []; 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((value) { if (value != null) lightKV.setStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS, usedIds); }).catchError((Object e) => debugLog('免费次数上报失败', e)); return true; } }