import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hgdj/hj_model/drama_media_info.dart'; import 'package:hgdj/hj_model/list_base_model.dart'; import 'package:hgdj/hj_model/media_content.dart'; import 'package:hgdj/hj_model/video_model.dart'; import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart'; import 'package:hgdj/hj_page/drama/view/drama_episode_sheet.dart'; import 'package:hgdj/hj_page/drama/view/drama_speed_sheet.dart'; import 'package:hgdj/hj_utils/api_service/drama_service.dart'; import 'package:hgdj/tools_base/debug_log.dart'; /// 短剧二级页:同一部剧连播,退出本页就回到 Feed 的逐剧模式 class DramaDetailLogic extends GetxController { // GetBuilder 局部刷新 id:翻页只刷信息流,倒计时只刷底部条 static const idFeed = 'feed'; static const idBar = 'bar'; // ===== 外部传入 ===== final DramaMediaInfo? drama; // 列表带过来的剧信息(先渲染,详情回来再覆盖) /// 指定先播第几集(下标);<0 表示不指定,用本地续播位置 final int initialIndex; /// 指定先播哪一集(分集 id)。优先级高于上面两者—— /// 缓存页只存了分集 id,且那一集常落在后面的分页里,按下标定位不到 final String? initialContentId; /// [initialIndex] 那一集的起播秒数,由一级页刷剧模式透传;其余情况为 0 int _startSeconds; DramaDetailLogic( this.drama, { this.initialIndex = -1, this.initialContentId, int initialSeconds = 0, }) : _startSeconds = initialSeconds; // ===== 状态 ===== bool isFirstLoaded = false; bool _isLoading = false; // 分集分页加载中,防并发 // ===== 数据 ===== DramaMediaInfo? _detail; //播放器要的 VideoModel:只在拉到分集时转一次并缓存。 //每次 build 现转会换新对象,播放器 didUpdateWidget 里跟着换 model,详情(权限)就会被反复重拉 List episodeVideos = []; //剧信息以详情为准,详情没回来先用列表带过来的 DramaMediaInfo? get info => _detail ?? drama; //分集列表挂在剧信息上 List get episodes => info?.episodeList ?? []; bool get isLast => pageIndex >= episodes.length - 1; // ===== 交互态 ===== int pageIndex = 0; int nextSeconds = 0; // >0 时底部条显示「Ns后播放下一集」 double speed = 1.0; // 播放倍速,整页共用:切集沿用,退页即回默认 // ===== Controller ===== late PageController pageCtr; final ValueNotifier isScrolling = ValueNotifier(false); // 上下滑动切集时隐藏进度条 @override void onInit() { super.onInit(); pageIndex = initialIndex < 0 ? 0 : initialIndex; pageCtr = PageController(initialPage: pageIndex); loadDetail(); } Future loadDetail() async { isFirstLoaded = false; update(); try { //三件事互不依赖:续播位置读本地盘,详情和分集都只认构造带进来的剧 id, //一起发出去,首屏从「读盘→详情→分集」三次串行缩成一次并发。 //两个接口必须走 Future.wait 一起等:分开 await 的话前一个抛异常后, //后一个没人接的 Future 就成了 unhandled async error(签名失败时两个会一起抛) final resumeTask = dramaResume.load(); // 读本地盘,内部已兜住异常 final resp = await Future.wait([ DramaService.fetchDetail(drama?.id), DramaService.fetchEpisodeList( drama?.id ?? '', 1, drama?.episodePageSize ?? 30), ]); await resumeTask; _detail = resp[0] as DramaMediaInfo? ?? drama; //分集要等详情落定再挂:info 会从 drama 换成 _detail,挂早了就写进被丢弃的那个对象里 _applyEpisodes(resp[1] as ListBaseModel?); if (initialContentId?.isNotEmpty == true) { //外部点的就是这一集(如缓存页点某一集),续播位置和下标都让路 _locateTo(await _indexOfContent(initialContentId)); } else if (initialIndex < 0) { _locateTo(await _indexOfContent(dramaResume.of(info?.id)?.contentId)); } else { //外部指定了集数(如 Feed 里第一集播完 → 进来接着播下一集),别被续播位置顶走 _locateTo(initialIndex); } _touchHistory(); } catch (e) { debugLog('短剧详情获取失败:$e'); } if (isClosed) return; // 请求期间可能已退页,别对已释放的 controller 刷新 isFirstLoaded = true; update(); } /// 进来就算看过一次:观看时间刷成现在,历史列表里排到最前,进度不动。 /// 不能只靠播放器落进度——秒退、或者进来就撞付费墙时它一条都不记(见 _saveResume 的三道闸) void _touchHistory() { final list = episodes; dramaResume.markWatched( mediaId: info?.id, contentId: list.isEmpty ? null : list[pageIndex.clamp(0, list.length - 1)].id, drama: info, ); } /// 把并发拉回来的第一页分集挂到最终的剧对象上 void _applyEpisodes(ListBaseModel? res) { final media = info; if (media == null) return; media.episodeList = res?.list; media.episodeCurPage = 1; media.haxNextEpisode = res?.hasNext ?? false; episodeVideos = (media.episodeList ?? []).map(media.toVideoModel).toList(); } /// 开短剧卡之后:整部剧一起放行。只重拉一次剧详情拿最新权限,分集不逐页重拉—— /// 付费角标就地清掉,具体哪一集能不能播,等翻到它时 _loadEpisode 拉分集详情为准 Future onCardUnlocked() async { final detail = await DramaService.fetchDetail(drama?.id); if (isClosed) return; final media = info; if (media == null) return; if (detail != null) { //分集那几个字段是本页自己维护的,别被新详情清空 detail.episodeList = media.episodeList; detail.episodeCurPage = media.episodeCurPage; detail.haxNextEpisode = media.haxNextEpisode; _detail = detail; } //置空而不是改成 bought:开了卡就是不该再挂付费标,真实状态以分集详情为准 for (final episode in info?.episodeList ?? []) { if (episode.accessType == 'coin' || episode.accessType == 'card') episode.accessType = null; } update([idFeed]); } /// 快翻到列表尾部时续拉下一页,别让连播在分页边界断掉。 /// 原地 addAll 而不是新建列表:选集面板持有的是同一个 List 引用,换了对象它就永远看不到新数据。 /// 只追加新的,也不整表重建 episodeVideos——重建会把在播的那几集换成新对象,播放器跟着重拉一遍详情 Future _loadMore() async { final media = info; if (media == null || _isLoading || media.haxNextEpisode != true) return; _isLoading = true; final page = (media.episodeCurPage ?? 1) + 1; final res = await DramaService.fetchEpisodeList( media.id ?? '', page, media.episodePageSize); _isLoading = false; if (isClosed) return; //拉空/失败都要落 haxNextEpisode,否则 _resumeIndex 的补页循环会一直转 if (res?.list?.isNotEmpty != true) { media.haxNextEpisode = false; return; } final more = res!.list!; (media.episodeList ??= []).addAll(more); media.episodeCurPage = page; media.haxNextEpisode = res.hasNext ?? false; episodeVideos.addAll(more.map(media.toVideoModel)); update([idFeed]); } /// 某一集在列表里的下标。目标集常落在后面的分页里,这里边翻页边找—— /// 分集按集数有序,找到那一页就停,拉的页数和「先算出第几集再拉够长度」一样多, /// 却不依赖「第 N 集就在第 N 个位置」(集数跳号就会算少)。没传/找不到(已下架)都回 0 Future _indexOfContent(String? contentId) async { if (contentId == null) return 0; var index = episodes.indexWhere((e) => e.id == contentId); while (index < 0 && !isClosed && info?.haxNextEpisode == true) { final before = episodes.length; await _loadMore(); if (episodes.length == before) break; // 没拉到新数据,别死转 index = episodes.indexWhere((e) => e.id == contentId); } return index < 0 ? 0 : index; } /// 定位到某一集(具体秒数由播放器按本地记录续)。 /// 下标必须钳在已加载范围内:只有 1 集的剧收到「播下一集」时会越界,PageView 直接崩。 /// isClosed 也要挡:补分页那几个请求还在飞时用户退了页,pageCtr 已在 onClose 里释放, /// 走到下面的 dispose 就是二次释放 void _locateTo(int index) { if (isClosed || episodes.isEmpty) return; final target = index.clamp(0, episodes.length - 1); if (target == pageIndex) return; pageIndex = target; //错误页重试进来时 PageView 可能还挂着旧 controller,那种情况只能跳页,dispose 会崩在 detach 上 if (pageCtr.hasClients) { pageCtr.jumpToPage(target); return; } pageCtr.dispose(); pageCtr = PageController(initialPage: target); } void onPageChanged(int index) { _clearNextTip(); if (pageIndex == index) return; pageIndex = index; update([idFeed]); if (episodes.length - index <= 5) _loadMore(); } /// 播放器报来的「距结束还剩 N 秒」:连播时底部条提前倒数,不是播完再等 5 秒 void onRemaining(int seconds) { if (isLast) return; // 最后一集不提示下一集 if (nextSeconds == seconds) return; nextSeconds = seconds; update([idBar]); } /// 本集播完:连播直接切下一集;最后一集播完就停,不回第一集 void onCompleted() { _clearNextTip(); if (isLast) return; pageCtr.animateToPage( pageIndex + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } void _clearNextTip() { if (nextSeconds == 0) return; nextSeconds = 0; update([idBar]); } /// 打开倍速面板。倍速挂在页面上不是某一集上,切集后新起的播放器也按它播 Future openSpeedSheet() async { final value = await DramaSpeedSheet.show(speed: speed); if (isClosed || value == null || value == speed) return; speed = value; update(); // 右上角按钮文案 + 各集播放器一起刷 } /// 打开选集面板,选中后跳到对应集 Future openEpisodeSheet() async { _clearNextTip(); final index = await DramaEpisodeSheet.show( drama: info, episodes: episodes, // 与 logic 共用同一个 List 实例,续拉的新集面板里能直接看到 selectedIndex: pageIndex, onLoadMore: _loadMore, ); //选集面板是异步的,回来时页面可能已经退掉,pageCtr 已释放 if (isClosed || index == null || index == pageIndex) return; pageCtr.jumpToPage(index); } /// 取走带进来的起播秒数:只给落地那一集,且只给一次—— /// PageView 滑远会把那一集的 State 回收,再滑回来重建时不清零的话会又 seek 回原位, /// 而滑回来的口径是从头重看 int takeStartSeconds(int index) { if (index != initialIndex || _startSeconds <= 0) return 0; final seconds = _startSeconds; _startSeconds = 0; return seconds; } /// 上下滑动切集时隐藏进度条。只认起止,中间的 update 通知不用管 void onScroll(ScrollNotification n) { if (n is ScrollStartNotification) { isScrolling.value = true; } else if (n is ScrollEndNotification) { isScrolling.value = false; } } // GetX 释放只走 onClose(),定时器/控制器都在这里清 @override void onClose() { pageCtr.dispose(); isScrolling.dispose(); super.onClose(); } }