726 lines
31 KiB
Dart
726 lines
31 KiB
Dart
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.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<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');
|
||
}
|
||
}
|
||
}
|