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 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((_) => 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 _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.realPlayUrl;265 由设备硬解能力 + 本条是否已运行时回退共同决定 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 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 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 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 _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'); } } }