Files
huangguo_android/lib/hj_page/video/video_logic.dart
T
2026-09-15 15:44:13 +07:00

726 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
}
}
}