初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+725
View File
@@ -0,0 +1,725 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/video/video_full_page.dart';
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
import 'package:hgdj/hj_utils/codec_support.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/play_diagnose.dart';
import 'package:hgdj/hj_utils/video_view_type.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:video_player/video_player.dart';
import '../../alert/video/buy_vip_alert.dart';
import '../../alert/video/comic_buy_alert.dart';
import '../../alert/vip_guide/guide_bottom_sheet.dart';
import '../../alert/vip_guide/guide_common.dart';
import '../../alert/vip_guide/guide_config.dart';
import '../../alert/vip_guide/guide_countdown_dialog.dart';
import '../../alert/vip_guide/guide_free_trial_sheet.dart';
import '../../alert/vip_guide/guide_manager.dart';
import '../../alert/vip_guide/timed_popup_manager.dart';
import '../../hj_model/acg/comic_chapters_model.dart';
import '../../hj_model/cartoon_media_info.dart';
import '../../hj_model/media_content.dart';
import '../../hj_model/splash/ads_model.dart';
import '../../hj_model/splash/domain_source_model.dart';
import '../../hj_model/video_model.dart';
import '../../hj_utils/api_service/acg_service.dart';
import '../../hj_utils/api_service/vid_service.dart';
import '../../hj_utils/free_play_manager.dart';
import '../../hj_utils/history_util.dart';
import '../../routers/jump_router.dart';
import '../../tools_base/ad_manager.dart';
import '../../tools_base/debug_log.dart';
import '../../tools_base/event_bus/event_bus_util.dart';
import '../../tools_base/event_bus/events.dart';
import '../../tools_base/global_store/store.dart';
import '../main_page/provider/msg_provider.dart';
import '../pre_sale/pre_sale_provider.dart';
/// 视频/动漫播放页业务逻辑:拉详情 → 初始化播放器;动漫多一步章节列表
class VideoLogic extends GetxController with GetSingleTickerProviderStateMixin {
VideoLogic({this.vid, this.isCartoon = false});
/// 当前播的是不是动漫。**可变**:页内切内容后按被点内容的 videoType 重定(见 [switchVideo])
/// 走错接口会「视频资源加载失败」。页内所有判断都读它,不看进页面时的入参
bool isCartoon;
final String? vid;
VideoModel? videoModel;
/// 当前选定的播放 URL(按 playContentStyle / 预览权 / 265能力等条件选择,见 [_initPlayer]
String? videoUrl;
String? get videoCover => videoModel?.cover;
VideoPlayerController? playerCtr;
/// 当前是否在播 H.265 源。**必须选流当下记录**,不能用 `videoUrl == realH265Url` 派生——
/// realH265Url 拿全局 token/cdn 现拼,刷新后对不上会误判 false,265 降级链就失效了
bool isPlayingH265 = false;
/// 本条视频是否已回退 264(仅内存)。初始化失败或播放中报错时置位,
/// 之后所有重建都走 264;换视频时 loadData 复位
bool _forceH264 = false;
/// 贴片广告列表(position=50
List<AdsInfoModel> adsList = [];
/// 预览结尾 VIP 弹窗的去重标记,防止重复弹
bool isShowDialog = false;
bool isShowAd = false; // true 显示视频广告
bool isFullScreen = false; // true:视频进入全屏页面
RxBool isShowBuy = false.obs; // 显示购买和vip弹窗
late final TabController tabCtr =
TabController(length: 2, vsync: this); // 简介/评论
CartoonMediaInfo? cartoonModel; //动漫
ComicChapterInfo? currentEpisode; //动漫集数
/// onClose 后的标记,用于防止异步回调里再操作已释放控制器
bool isDispose = false;
LongVideoStatus get videoStatus => longVideoStatus(videoModel);
/// 「金币视频免看」权益:VIP+coins==0 或 剩余免次数>=0 才免看;非金币视频/动漫不享受
bool get isVipCoinFree {
if (videoModel?.isCoinVideo() != true) {
// 非金币视频
return false;
}
if (globalStore.isVIP && videoModel?.coins == 0) {
// vip金币视频 免看
return true;
} else {
if (isCartoon == true) return false; // 动漫视频不享受金币免次数权益
if (presaleProvider.coinVideoFreeCount >= 0) return true;
return false;
}
}
bool get isInited => playerCtr?.value.isInitialized == true; //视频控制器是否加载
double get normalRatio => 16 / 9;
/// 实际播放比例:动漫和未初始化都用 16:9 兜底,否则用控制器报告的实际比例
double get videoRatio =>
(!isCartoon && isInited) ? playerCtr!.value.aspectRatio : normalRatio;
StreamSubscription? _pauseVideoSub;
@override
void onInit() {
loadData();
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露
_pauseVideoSub = eventBus.on<PauseVideoEvent>((_) => playerCtr?.pause());
super.onInit();
}
/// 支付分层首弹:不分新人/老人,全局只弹一次(只看是否弹过,不再判断配置卡)
void alertVipFirst() async {
if (await MineMsgProvider.isPayPopupShown()) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isDispose) return;
BuyVipAlert.show(videoId: videoModel?.id);
MineMsgProvider.markPayPopupShown();
});
}
/// 新用户(newUnpay)用免费次数观看的视频,进播放页停留满5分钟弹一次付费引导。
/// 仅首次进入页面计时,页面内切集/换源不重启(一次页面只弹一次);计时/校验/取消交给 TimedPopupManager。
void _startFreeWatchTimer() {
TimedPopupManager().schedule(
type: TimedPopupType.freeWatchPay,
delay: const Duration(minutes: 5),
//到点校验:场景开关开着、仅长视频、新用户、未开会员、仍在用免费次数观看
//按到点时在播的内容判断:中途切到动漫就不弹了
canShow: () =>
!isDispose &&
!isCartoon &&
!globalStore.isVIP &&
GuideManager().canShow(GuideFreeTrialSheet.scene) &&
MineMsgProvider().payTier?.status == PayTier.newUnpay &&
FreePlayManager().useFreePlay(videoModel) == true &&
GuideFreeTrialSheet
.isCountLow, //必须排在 useFreePlay 之后:它会先扣一次,扣完才是该显示的剩余数
onShow: () async {
// 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下
if (await loadGuideCard(GuideFreeTrialSheet.scene) == null || isDispose)
return;
// 只暂停本来在播的:用户自己停在那看简介/评论时,弹完别擅自续播
final wasPlaying = playerCtr?.value.isPlaying == true;
if (wasPlaying)
playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update()
final goVip = await GuideFreeTrialSheet.show();
// 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放
if (!wasPlaying || goVip || isDispose || isShowBuy.value) return;
playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY
},
);
}
bool _payGuideShown = false; // 本页「付费前置提示」是否已弹过(一次页面只弹一次)
/// 要花金币买或要开会员才能看完整片(不要求配了试看地址)
bool get _needPayGuide => videoStatus.isNeedPay;
/// 能连续看的秒数:有试看片就是试看片长,否则是正片免费时长
int get _freeSeconds => videoModel?.previewURL?.isNotEmpty == true
? (playerCtr?.value.duration.inSeconds ?? 0)
: (videoModel?.freeTime ?? 0);
/// 各类「整片免看」权益:免费次数 / 免费区 / 动漫整本已购 / 动漫免费章节 / VIP 金币限免。
/// 命中任一说明用户本来就能看完整片,场景1、场景2 都不该打扰他。
/// [_onProgress] 与场景1 共用,避免两边判断漂移。
/// (useFreePlay 有扣次数副作用,但 _initPlayer 起手已调过一次,之后都走 usedIds 幂等返回)
bool get _hasFreeWatchRight {
if (FreePlayManager().useFreePlay(videoModel)) return true;
if (videoModel?.freeArea == true) return true;
if (cartoonModel?.mediaStatus?.hasPaid == true)
return true; // 动漫整本解锁, 都可以看, 不管vip权限
if (isCartoon &&
cartoonModel?.isFreeEpisodeNumber(currentEpisode?.episodeNumber) ==
true) return true;
return isVipCoinFree;
}
/// 起播前置引导(VIDEO_PREVIEW_END):需付费的视频起播 3 秒后前置弹一次购买引导
/// (暂停,关掉后续播,一次进页面只弹一次)。
/// 与超免费时长/试看结束的硬拦截(BuyVipAlert)都保留;本引导弹前会 pause,
/// 期间进度不走、硬拦截触发不了,两者天然错开,不靠 TimedPopupManager 互斥。
/// 计的是起播后的墙钟 3 秒(不要求真播满 3 秒进度);暂停后再播会重新计时。
void _startPayGuideTimer() {
if (_freeSeconds < 3) return; // 不足3秒:没等到提示就已被试看结束的硬拦截拦下了
TimedPopupManager().schedule(
type: TimedPopupType.videoPayAhead,
delay: const Duration(seconds: 3),
//到点再校验一次:3秒内买了 / 开了会员 / 进了全屏都不弹(全屏弹会连横竖屏一起乱)
//_hasFreeWatchRight:能免费看完整片的别拿"只能看试看"的文案打扰他
canShow: () =>
!isDispose &&
!_payGuideShown &&
!isFullScreen &&
_needPayGuide &&
!_hasFreeWatchRight &&
GuideManager().canShow(GuideScene.videoPreviewEnd),
onShow: () async {
// 到点还在播才弹(缓冲期 isPlaying 仍为 true,不违背"起播3秒")。
// 挡掉:这3秒里手动暂停了 / 点推荐开了新视频页(先 pause 再 push,旧页没销毁、Timer 照跑)
if (playerCtr?.value.isPlaying != true) return;
_payGuideShown = true;
// 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下
if (await loadGuideCard(GuideScene.videoPreviewEnd) == null ||
isDispose) return;
playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update()
final goVip = await _showPayGuide();
// 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放
if (goVip || isDispose || isShowBuy.value) return;
playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY
},
);
}
/// 起播前置引导弹窗(VIDEO_PREVIEW_END)。返回 true = 点了开通并已跳会员页
/// (试看结束/超时长已改弹 BuyVipAlert 硬拦截,不再共用这套文案)
/// 长视频不传 buttonText,走默认「立即开通 · 仅需¥价格」
Future<bool> _showPayGuide() => GuideBottomSheet.showVideo();
bool _backGuideShown = false; // 本页「返回优惠挽留」是否已弹过(一次页面只弹一次)
/// 是否允许直接退页。false=先弹返回挽留(VIDEO_BACK):长视频 + 场景开关允许 + 本页未弹过。
/// 供 PopScope.canPop 与返回箭头共用,类型跟当前在播内容走
bool get canPop =>
_backGuideShown ||
isCartoon ||
!GuideManager().canShow(GuideScene.videoBack);
/// VIDEO_BACK:长视频返回挽留。弹优惠倒计时,标记已弹并 update() 让 canPop 放行后续返回。
/// 用户是按了返回才弹的,所以挽留失败(点X/点蒙层/被互斥挡下)就把这次返回执行完;
/// 点了「立即开通」已跳会员页则留在本页,从会员页回来还能接着看
void showBackGuide() async {
if (_backGuideShown) return;
_backGuideShown = true;
update(); // 刷新 PopScope.canPop → 关掉弹窗后再次返回可直接退页
var goVip = false;
// 取卡链路(签名/网络)抛异常绝不能让返回失灵:吞掉照样放行返回
try {
await TimedPopupManager().trigger(
canShow: () => GuideManager().canShow(GuideScene.videoBack),
onShow: () async => goVip =
await GuideCountdownDialog.show(scene: GuideScene.videoBack),
);
} catch (e) {
debugLog('showBackGuide', e.toString());
}
if (!goVip && !isDispose) Get.back();
}
/// UI 返回箭头统一入口:可退直接退,否则弹返回挽留。
void onBackPressed() {
if (canPop) {
Get.back();
} else {
showBackGuide();
}
}
/// 拉取视频/动漫详情,写入观看历史,初始化播放
/// - [newId] 非空:视频内切换集数 / 切换视频时复用本流程,传新 id
Future loadData({String? newId}) async {
if (isDispose) return;
_forceH264 = false; // 新视频复位:重新按设备能力试 265(运行时回退不跨视频)
final id = newId ?? videoModel?.id ?? vid ?? "";
Object? detailError; // 诊断用:拉详情抛的异常
try {
if (isCartoon == true) {
cartoonModel = await ACGService.getMediaInfo(id);
int curPage = cartoonModel?.episodeCurPage ?? 0;
final episodeModel = await ACGService.getChapterList(
id,
curPage + 1,
cartoonModel?.episodePageSize ?? 40,
sortType: cartoonModel?.isUpSort,
);
cartoonModel?.episodeList = episodeModel?.list;
if (episodeModel?.list?.isNotEmpty != true) {
showToast("资源配置错误~");
Get.back();
return;
}
cartoonModel?.episodeCurPage = curPage + 1;
cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true;
videoModel?.mediaInfo = cartoonModel;
currentEpisode = episodeModel?.list?.first;
MediaContent? mediaContent =
await ACGService.getMediaDetail(id: currentEpisode?.id ?? "");
episodeModel?.list?.first.mediaContent = mediaContent;
videoModel = cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel();
} else {
videoModel = await VidService.getDetail(id);
}
isShowBuy.value = false;
update();
} catch (e) {
detailError = e; // 留住异常给诊断用:为空说明接口通了但 data 是 null(解析失败/服务端空数据)
debugLog(e.toString());
}
if (isDispose) return; // 拉详情期间可能已退页
// 网络失败时 videoModel/mediaInfo 为 null,直接用 videoModel! 会崩("Null check operator"),兜底退出
if (videoModel == null || (isCartoon && videoModel?.mediaInfo == null)) {
PlayDiagnose.report(
scene: '视频详情接口失败',
error: detailError ?? '接口无异常但返回 null(解析失败或服务端空数据)',
videoId: id,
videoTitle: isCartoon ? '动漫' : '视频',
);
showToast("视频资源加载失败!");
Get.back();
return;
}
if (isCartoon == true) {
HistoryUtil.insert(videoModel!.mediaInfo!, MediaStyle.Cartoon);
} else {
HistoryUtil.insert(videoModel!, MediaStyle.Video);
}
_initPlayer();
// 免费次数满5分钟引导 & 支付分层首弹:都只在首次进入页面触发,页面内切集/换源不重复(一次页面只弹一次)
if (newId == null) {
_startFreeWatchTimer();
alertVipFirst();
}
}
/// 购买成功:标记已购 + 关蒙层 + 重建播放器切正片。
/// 只改 hasPaid 不重建的话画面还停在预览片,必须重走 _initPlayer 重新选流,且从头播
void onBuySuccess() {
if (isDispose) return;
isShowDialog = false;
isShowBuy.value = false;
videoModel?.vidStatus?.hasPaid = true;
startPlay(resetPos: true);
update();
}
/// CDN 路线切换:同视频换 URL 重新起播(由 VideoTabbarMenuWidget 选完线路回调)
void onSwitchLine() {
startPlay();
update();
}
//hasAd: false 没有广告, true 广告还在
void setAdShowing(bool hasAd) {
isShowAd = hasAd;
}
/// 开始播放
/// 注:贴片广告期间不直接 play,由广告结束回调触发本方法(见 video_page.dart 的 onFinish
void play() {
if (isDispose || isShowAd) return; // 有贴片广告时不自动播,等广告结束回调
playerCtr?.play();
_startPayGuideTimer(); // 需付费的起播满3秒 → 前置购买引导(场景1);不满足条件的它自己会挡
}
/// 进入全屏(贴片广告中或控制器未就绪 → 不进)
void toFullPage() async {
if (isShowAd || playerCtr?.value.isInitialized != true) {
return;
}
isFullScreen = true;
await Get.to(
() => VideoFullPage(playCtr: playerCtr!, videoModel: videoModel));
isFullScreen = false;
}
/// 退出全屏
void exitFullScreen() {
if (!isFullScreen) return;
isFullScreen = false;
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top]);
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
Get.back();
}
/// 点击广告跳金币充值,回到本页时如原本在播放则恢复播放
void clickAdToVip() async {
bool isPlaying = playerCtr?.value.isPlaying ?? false;
playerCtr?.pause();
await pushToWalletPage(tabPosition: 0);
if (isPlaying) play();
await globalStore.updateUserInfo();
if (globalStore.isVIP) {
update();
}
}
/// 初始化播放器:拉贴片广告 → 定广告展示 → 选流(中文/无码/预览/265) → init + 挂 [_onProgress]。
/// 仅网络播放不走本地文件;[pos] >2 时续播到该秒(切线路场景)
void _initPlayer({int pos = 0, bool isRetry = false}) async {
if (isDispose) return;
FreePlayManager().useFreePlay(videoModel);
adsList = AdManager().adsByType(50);
//vip/已购视频不展示广告;否则配置了广告且配了关闭时间才展示
final noAd = globalStore.isVIP || videoModel?.vidStatus?.hasPaid == true;
setAdShowing(!noAd && adsList.firstOrNull?.hasAdTime == true);
// 选流整合进 model.realPlayUrl265 由设备硬解能力 + 本条是否已运行时回退共同决定
videoUrl = videoModel?.realPlayUrl(
canPlayPreview: _isPreview(),
useH265: CodecSupport.useH265 && !_forceH264,
);
// 选中的就是 265 源时当场记录(此刻 token/cdn 与拼 videoUrl 时一致,比较可靠)
final h265Url = videoModel?.realH265Url ?? '';
isPlayingH265 = h265Url.isNotEmpty && videoUrl == h265Url;
playerCtr = PlayerFactory.network(videoUrl); // 仅网络播放,不走本地文件
try {
await playerCtr?.initialize();
if (isRetry)
confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地
update();
} catch (e) {
debugLog("视频初始化失败:$e play url = $videoUrl");
playerCtr?.dispose(); // 初始化失败要释放,否则半初始化的解码器一直被占,多次后所有视频都加载失败
playerCtr = null;
if (isDecoderError(e)) {
// 1) 先按芯片兼容机制内存切 platformView 重试(保留硬解/265,海思等渲染错常能救回,不误丢 265)
if (switchToPlatformView()) {
_initPlayer(pos: pos, isRetry: true);
return;
}
// 2) platformView 也救不了且当前放的是 265:本机永久禁用 265 并落本地,用 264 重试
if (isPlayingH265 && CodecSupport.disableForDevice()) {
_initPlayer(pos: pos, isRetry: true);
return;
}
}
// 3) 非解码类错误(404/域名不通/非法 m3u8):只是这条 265 资源坏,与芯片无关 → 仅本条回退 264。
// isRetry 透传而非写死 true:没切过 platformView 就别让 264 播通了去误标它
if (isPlayingH265 && !_forceH264) {
_forceH264 = true;
debugLog("H.265 资源不可用,回退 264 兜底重试");
_initPlayer(pos: pos, isRetry: isRetry);
return;
}
PlayDiagnose.report(
scene: '长视频播放失败',
error: e,
url: videoUrl,
videoId: videoModel?.id,
videoTitle: videoModel?.title,
isH265: isPlayingH265,
forceH264: _forceH264,
);
showToast("视频资源加载失败!");
return;
}
if (isDispose) {
playerCtr?.pause();
playerCtr?.dispose();
playerCtr = null; // 退页后才初始化完成:释放并置空,防止 UI 重建摸到已 dispose 的控制器
return;
}
presaleProvider.useCoinPrivilege(videoModel);
playerCtr?.addListener(_onProgress);
int previewStart = videoModel?.previewStart ?? 0;
if (previewStart > 0) {
final canFree = FreePlayManager().useFreePlay(videoModel) ||
videoModel?.freeArea == true;
// 免金币视频 / 有免费观看次数(vip视频):从头播,不需要 seekTo
// 需要付费/会员才能看的视频,跳到预览起始位置开始播放
if (!canFree && videoStatus.isNeedPay) {
await playerCtr?.seekTo(Duration(seconds: previewStart));
}
} else if (pos > 2) {
await playerCtr?.seekTo(Duration(seconds: pos));
}
play();
update();
}
/// 播放进度监听(每帧):有免看权益的全程不拦截;
/// 正片到免费时长弹购买,预览片播完回 0 + 暂停 + 弹窗
void _onProgress() async {
// 265 运行时降级:播放器报错立即回退 264(不持久化)
if (isPlayingH265 && playerCtr?.value.hasError == true) {
_fallbackTo264();
return;
}
int duration = playerCtr?.value.duration.inSeconds ?? 0;
if (_hasFreeWatchRight) return; // 免费次数/免费区/动漫已购/免费章节/VIP金币限免,整片随便看
if (!_isPreview()) {
//每帧都走这里,videoStatus 是重算 getter,只读一次
final status = videoStatus;
final inFreeTime =
videoModel?.isInFreeTime(playerCtr!.value.position.inSeconds) == true;
final isPlaying = playerCtr?.value.isPlaying == true;
if (inFreeTime || !isPlaying) return;
// 全屏中:仍处于免费时长则不打扰;否则需付费的视频在播放时先退出全屏,给后续弹窗腾出空间
final wasFullScreen = isFullScreen;
if (wasFullScreen && status.isNeedPay) exitFullScreen();
//超过免费时间:需金币购买 / 需开会员,都弹购买蒙层
if (status.isNeedPay) {
showBuyMask();
// 非会员且免费次数已用完的 VIP 片:蒙层之上直接弹开通弹窗
// (金币片仍只显示蒙层,由蒙层引导金币解锁/观影券;动漫的弹窗在 showBuyMask 内部处理,别在这叠)
// showBuyMask 已 pause,下一帧 isPlaying=false 就提前 return,不会重复弹
if (!isCartoon && status.isNeedVip) {
// 刚退全屏要等 pop 动画和转屏落定再弹:exitFullScreen 的 Get.back() 带动画,
// 此刻 push 会叠在正在 pop 的全屏页上,且弹窗高度取的是实时 Get.height,横屏下算出来是错的
if (wasFullScreen)
await Future.delayed(const Duration(milliseconds: 350));
if (!isDispose) BuyVipAlert.show(videoId: videoModel?.id);
}
}
} else {
//预览视频逻辑处理
if (playerCtr!.value.position.inSeconds == duration - 1) {
await playerCtr!.seekTo(Duration.zero);
playerCtr?.pause();
exitFullScreen();
Future.delayed(const Duration(milliseconds: 200), () async {
if (isDispose) return;
if (videoModel?.coins != 0) {
showBuyMask();
} else if (videoModel?.coins == 0 && videoModel?.freeArea != true) {
///点击的必须弹出
if (!isShowDialog) {
isShowDialog = true;
unawaited(showBuyMask()); // 只等蒙层显示,不等动漫购买弹窗关闭(与原逻辑一致)
isShowDialog = false;
await globalStore.updateUserInfo();
//已开通 VIP → 重启播完整片;否则保持暂停(蒙层已显示,由蒙层引导开通),
//不能再 play() 恢复预览,否则"试看结束"蒙层还在、视频却在后面继续播
if (globalStore.isVIP) {
startPlay();
update();
} else if (!isDispose) {
// 试看结束:所有非会员统一弹开通弹窗,under7/over7 原来弹吸底分层引导,现收敛成一个
// 不走 TimedPopupManager:这是硬拦截,被互斥挡下就等于没弹
await BuyVipAlert.show(videoId: videoModel?.id);
}
}
}
});
}
}
}
/// 265 播放中报错:仅本条回退 264 续播,不持久化。
/// 回退后 isPlayingH265 自然为 false 不会重入,_forceH264 再兜住重建中的窗口期
void _fallbackTo264() {
if (isDispose || !isPlayingH265 || _forceH264) return;
_forceH264 = true; // 本条视频后续重建都走 264
debugLog(
"H.265 运行时报错,回退 264 续播 pos=${playerCtr?.value.position.inSeconds}");
releasePlayer(); // 释放当前 → 下一帧按进度续播,选流因 _forceH264 走 264
}
/// 弹购买/VIP 蒙层(暂停 + 显示)。动漫 permission==1 走单集/全集购买(result 1单集 2全集)
/// 其他走 VIP 弹窗;长视频只暂停+显示,交给 VideoMaskBuyView
Future<void> showBuyMask() async {
playerCtr?.pause();
isShowBuy.value = true;
update();
if (isCartoon) {
if (videoModel?.mediaInfo?.permission == 1) {
var result = await ComicBuyAlert.show(mediaInfo: videoModel?.mediaInfo);
///true表示支付成功
if (result == 1 || result == 2) {
videoModel?.vidStatus?.hasPaid = true;
currentEpisode?.hasBuy = true;
if (result == 2) {
// 全集购买
videoModel?.mediaInfo?.mediaStatus?.hasPaid = true;
}
playerCtr?.play();
}
update();
} else {
await BuyVipAlert.show(videoId: videoModel?.id);
}
}
}
/// 是否走预览视频(短预告片)
/// 条件:用户没有免播次数 + 配了 previewURL + 视频需要付费/VIP
bool _isPreview() {
if (FreePlayManager().useFreePlay(videoModel) == true) return false;
return videoModel?.previewURL?.isNotEmpty == true && videoStatus.isNeedPay;
}
/// 推荐区切内容(释放控制器 + 重拉详情起播,不开新页)。
/// **必须按被点内容的 videoType 定接口**,用页面入参会串接口 → 「视频资源加载失败」
Future<void> switchVideo(VideoModel model) async {
videoModel = model;
isShowBuy.value = false;
isCartoon = model.videoType == 1;
if (!isCartoon) {
// 切到真人视频:清掉上一部动漫的章节态,避免选集/免费章节逻辑串到真人视频
cartoonModel = null;
currentEpisode = null;
}
final old = playerCtr;
old?.pause();
old?.removeListener(_onProgress);
WidgetsBinding.instance.addPostFrameCallback((_) => old?.dispose());
loadData(newId: model.id);
playerCtr = null;
update();
}
/// 切视频或切 CDN 路线:[model] 非空=切新视频,为空=同视频换 URL;控制器为空走首次进入路径
Future<void> startPlay({VideoModel? model, bool resetPos = false}) async {
if (model != null) {
videoModel = model;
}
if (playerCtr == null) {
_initPlayer();
} else {
releasePlayer(model: model, resetPos: resetPos);
update();
}
}
/// 动漫切换集数:同集 → toast 提示;不同集 → 释放当前 + 拉新集 mediaContent + 启动播放
void switchEpisode(ComicChapterInfo episode) async {
if (episode.id == currentEpisode?.id) {
showToast("正在播放");
return;
}
try {
currentEpisode = episode;
isShowBuy.value = false;
releasePlayer(rebuild: false);
update();
currentEpisode?.mediaContent ??=
await ACGService.getMediaDetail(id: currentEpisode?.id ?? "");
startPlay(
model: cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel());
} catch (e) {
debugLog(e);
}
}
/// 释放播放器。[rebuild]=false 只释放(切集前清理);[model] 非空则切新视频,为空则按上次进度续播。
/// dispose 走下一帧,避免在播放回调中销毁导致 use-after-free
void releasePlayer(
{VideoModel? model, bool rebuild = true, bool resetPos = false}) {
final old = playerCtr;
old?.pause();
// resetPos:预览片→正片场景,两者时间轴不对应,不能沿用预览片进度,从头播
final pos = resetPos ? 0 : (old?.value.position.inSeconds ?? 0);
old?.removeListener(_onProgress);
WidgetsBinding.instance.addPostFrameCallback((_) {
old?.dispose();
if (isDispose || !rebuild) return; // 退页后别再重建播放器,否则野解码器泄漏
if (model != null) {
isShowBuy.value = false;
update();
loadData(newId: model.id);
} else {
_initPlayer(pos: pos);
}
});
playerCtr = null;
}
/// 页面销毁清理:取消事件订阅 → 非预览视频上报观看记录 → 释放播放器
@override
void onClose() {
isDispose =
true; // 第一时间置位,让所有异步回调(initialize/postFrame/Future.delayed)能尽早 return
TimedPopupManager().cancel(TimedPopupType.freeWatchPay);
TimedPopupManager().cancel(TimedPopupType.videoPayAhead);
playerCtr?.pause();
playerCtr?.removeListener(_onProgress);
_pauseVideoSub?.cancel();
if (playerCtr != null) {
bool isPreview = false;
if (videoModel?.previewURL?.isNotEmpty == true) {
if (playerCtr?.dataSource.contains(videoModel?.previewURL ?? "") ==
true) {
isPreview = true;
}
}
if (!isPreview) {
try {
_reportPlay(
playerCtr?.value.position, playerCtr?.value.duration, videoModel);
} catch (e) {
debugLog(e);
}
}
}
playerCtr?.dispose();
playerCtr = null;
tabCtr.dispose();
super.onClose();
}
/// 上报播放记录(static:不碰实例状态,避免请求在飞时把已销毁的 logic 一起吊住)
static Future<void> _reportPlay(
Duration? position, Duration? duration, VideoModel? video) async {
if (position == null || duration == null) return;
if (position.inMilliseconds < 1000) return; // 不足 1 秒不记
if (video?.videoType == 1) return; // 动漫不走这个接口
// 播放器拿不到时长时退回视频配置的 playTime
var totalMs = duration.inMilliseconds;
if (totalMs <= 0) totalMs = (video?.playTime ?? 0) * 1000;
if (totalMs <= 0) return; // 时长未知,算不出进度
try {
await VidService.sendRecord(
video?.id ?? '',
playWay: video?.coins == 0 ? 0 : 1,
longer: position.inSeconds,
progress: (position.inMilliseconds / totalMs * 100).toInt(),
via: 1,
tagID: video?.tags?.firstOrNull?.id ?? '',
);
} catch (e) {
debugLog('_reportPlay', 'error:$e');
}
}
}