初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
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 = <String>[];
/// 次数/已看列表变更版本号:列表角标监听它,会话内试看次数用尽即刷新,不必重拉列表。
final ValueNotifier<int> revision = ValueNotifier<int>(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<void> refresh() async {
remain = await VidService.fetchWatchCount();
final saved =
await lightKV.getStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS) ??
const <String>[];
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<void>((value) {
if (value != null)
lightKV.setStringList(StoreKeys.NEW_FREE_WATCH_VIDEOS, usedIds);
}).catchError((Object e) => debugLog('免费次数上报失败', e));
return true;
}
}