初始化
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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<VideoModel> episodeVideos = [];
|
||||
|
||||
//剧信息以详情为准,详情没回来先用列表带过来的
|
||||
DramaMediaInfo? get info => _detail ?? drama;
|
||||
|
||||
//分集列表挂在剧信息上
|
||||
List<MediaContent> 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<bool> isScrolling = ValueNotifier(false); // 上下滑动切集时隐藏进度条
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
pageIndex = initialIndex < 0 ? 0 : initialIndex;
|
||||
pageCtr = PageController(initialPage: pageIndex);
|
||||
loadDetail();
|
||||
}
|
||||
|
||||
Future<void> 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<MediaContent>?);
|
||||
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<MediaContent>? 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<void> 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 ?? <MediaContent>[]) {
|
||||
if (episode.accessType == 'coin' || episode.accessType == 'card')
|
||||
episode.accessType = null;
|
||||
}
|
||||
update([idFeed]);
|
||||
}
|
||||
|
||||
/// 快翻到列表尾部时续拉下一页,别让连播在分页边界断掉。
|
||||
/// 原地 addAll 而不是新建列表:选集面板持有的是同一个 List 引用,换了对象它就永远看不到新数据。
|
||||
/// 只追加新的,也不整表重建 episodeVideos——重建会把在播的那几集换成新对象,播放器跟着重拉一遍详情
|
||||
Future<void> _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<int> _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<void> openSpeedSheet() async {
|
||||
final value = await DramaSpeedSheet.show(speed: speed);
|
||||
if (isClosed || value == null || value == speed) return;
|
||||
speed = value;
|
||||
update(); // 右上角按钮文案 + 各集播放器一起刷
|
||||
}
|
||||
|
||||
/// 打开选集面板,选中后跳到对应集
|
||||
Future<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_detail_logic.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_bottom_bar.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_video_player.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
|
||||
/// 短剧二级页:竖划切集 + 底部剧集条(选集/自动续播)
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
final DramaMediaInfo? drama;
|
||||
|
||||
/// 指定进来先播第几集(下标);<0 表示不指定,交给续播位置决定
|
||||
final int initialIndex;
|
||||
|
||||
/// 一级页刷剧模式带进来的秒数:只作用在 [initialIndex] 那一集。
|
||||
/// 从历史记录/收藏进来的不传,那边的位置由本地续播记录决定
|
||||
final int initialSeconds;
|
||||
|
||||
/// 指定先播哪一集(分集 id),优先级高于 [initialIndex] 和续播位置。缓存页点某一集进来用
|
||||
final String? initialContentId;
|
||||
|
||||
const DramaDetailPage({
|
||||
super.key,
|
||||
this.drama,
|
||||
this.initialIndex = -1,
|
||||
this.initialSeconds = 0,
|
||||
this.initialContentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DramaDetailPage> createState() => _DramaDetailPageState();
|
||||
}
|
||||
|
||||
class _DramaDetailPageState extends State<DramaDetailPage> with UniqueTagMixin {
|
||||
//底部剧集条整体高度(36 内容 + 上下 6 外边距),播放器进度条据此让位
|
||||
static const _barHeight = 48.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
body: GetBuilder<DramaDetailLogic>(
|
||||
tag: uniqueTag, // 可重复 push,按实例隔离
|
||||
init: DramaDetailLogic(
|
||||
widget.drama,
|
||||
initialIndex: widget.initialIndex,
|
||||
initialContentId: widget.initialContentId,
|
||||
initialSeconds: widget.initialSeconds,
|
||||
),
|
||||
builder: (logic) {
|
||||
if (!logic.isFirstLoaded) return const LoadingCenterWidget();
|
||||
if (logic.episodeVideos.isEmpty)
|
||||
return CErrorWidget(retryOnTap: logic.loadDetail);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_episodeFeed(),
|
||||
_backButton(),
|
||||
_speedButton(logic),
|
||||
Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 竖向翻集:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage
|
||||
Widget _episodeFeed() {
|
||||
return GetBuilder<DramaDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
id: DramaDetailLogic.idFeed,
|
||||
builder: (logic) => NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
logic.onScroll(n);
|
||||
return false;
|
||||
},
|
||||
child: PageView.builder(
|
||||
controller: logic.pageCtr,
|
||||
onPageChanged: logic.onPageChanged,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
scrollDirection: Axis.vertical,
|
||||
allowImplicitScrolling: true,
|
||||
itemCount: logic.episodeVideos.length,
|
||||
itemBuilder: (context, index) {
|
||||
//每集的 VideoModel.id 都是剧 id,key 必须用子集 id,否则 PageView 会跨集复用 State
|
||||
return DramaVideoPlayer(
|
||||
key: ValueKey(logic.episodes[index].id ?? index),
|
||||
index: index,
|
||||
isCurrentPage: logic.pageIndex == index,
|
||||
videoInfo: logic.episodeVideos[index],
|
||||
showEntry: false, // 已经在剧里了,剧名不带箭头、底部条由本页画
|
||||
isScrolling: logic.isScrolling,
|
||||
progressBottom: _barHeight + Get.mediaQuery.padding.bottom,
|
||||
speed: logic.speed,
|
||||
//带进来的秒数只给落地那一集,其余从本地续播位置起播
|
||||
startSeconds: logic.takeStartSeconds(index),
|
||||
onCompleted: logic.onCompleted,
|
||||
onRemaining: logic.onRemaining,
|
||||
onCardUnlocked: logic.onCardUnlocked,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部剧集条:正常展示「观看完整剧集·全N集」,距本集结束 5 秒内切成「Ns后播放下一集」
|
||||
Widget _bottomBar() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: GetBuilder<DramaDetailLogic>(
|
||||
tag: uniqueTag,
|
||||
id: DramaDetailLogic.idBar,
|
||||
builder: (logic) {
|
||||
final seconds = logic.nextSeconds;
|
||||
return DramaBottomBar(
|
||||
highlight: seconds > 0 ? '${seconds}s后' : null,
|
||||
text: seconds > 0
|
||||
? '播放下一集'
|
||||
: '观看完整剧集·全${logic.info?.totalEpisode ?? logic.episodes.length}集',
|
||||
onTap: logic.openEpisodeSheet,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右上角倍速入口(设计稿 NavBar:秒表 18 + 间距 4 + 12/中等,无底无框)
|
||||
Widget _speedButton(DramaDetailLogic logic) {
|
||||
return Positioned(
|
||||
top: 20,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
//高度对齐左上角返回键(24 图标 + IconButton 的 8 内边距 = 40),两边看着在一条线上
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.openSpeedSheet,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
//导出的图标描边是 #292929,设计稿里是白的,运行时染白
|
||||
SvgPicture.asset(
|
||||
'stopwatch.svg'.videoPath,
|
||||
width: 18,
|
||||
height: 18,
|
||||
colorFilter:
|
||||
const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
'${logic.speed}x',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
height: 18 / 12,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backButton() {
|
||||
return Positioned(
|
||||
top: 20,
|
||||
child: SafeArea(
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black, offset: Offset(1, 1), blurRadius: 1)
|
||||
],
|
||||
),
|
||||
onPressed: Get.back,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// AI短剧信息流:一条 = 一部剧的第 1 集,竖划换下一部剧。
|
||||
/// 排序、去重、已看沉底全在服务端,**没有页码**——每次请求就是下一批,客户端不得二次排序,
|
||||
/// 也不做二次去重(服务端目前会重发同一部剧,去重就意味着列表不增长、用户直接划不动)
|
||||
class DramaFeedLogic extends GetxController {
|
||||
// GetBuilder 局部刷新 id:当前页变化时只刷信息流
|
||||
static const idFeed = 'feed';
|
||||
|
||||
// ===== 外部传入 =====
|
||||
final TabController? superTabCtr; // 外层频道 tab,用于切到本 tab 时补加载
|
||||
final int? tabIndex;
|
||||
|
||||
DramaFeedLogic({this.superTabCtr, this.tabIndex});
|
||||
|
||||
// ===== 状态 =====
|
||||
bool isFirstLoaded = false; // 首屏是否加载完成(false 时显示 loading)
|
||||
bool isLoading = false; // 防并发翻页
|
||||
bool hasNext = true;
|
||||
|
||||
/// 本批的幂等键:网络重试沿用,拿到结果才作废
|
||||
String? _requestId;
|
||||
|
||||
// ===== 数据 =====
|
||||
List<VideoModel>? list;
|
||||
|
||||
// ===== 交互态 =====
|
||||
int pageIndex = 0; // 当前显示第几条
|
||||
|
||||
// ===== Controller =====
|
||||
late PageController pageCtr;
|
||||
final ValueNotifier<bool> isScrolling = ValueNotifier(false); // 上下滑动切剧时隐藏进度条
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
pageCtr = PageController(initialPage: pageIndex);
|
||||
superTabCtr?.addListener(_onTabChanged);
|
||||
//续播位置是本地记的,进来就把表读上来。不用 await:读的是本地 sqflite,比首批网络请求快得多,
|
||||
//播放器起播时(要等 /media_content/info 回来)必然已就绪
|
||||
dramaResume.load();
|
||||
loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
superTabCtr?.removeListener(_onTabChanged);
|
||||
pageCtr.dispose();
|
||||
isScrolling.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// 切到本 tab 且还没数据就补一次
|
||||
void _onTabChanged() {
|
||||
if (superTabCtr?.index == tabIndex && list?.isEmpty == true) loadData();
|
||||
}
|
||||
|
||||
/// [isRefresh] 下拉刷新时清空重来,否则往后追加
|
||||
Future<void> loadData({bool isRefresh = true}) async {
|
||||
if (isLoading) return;
|
||||
if (!isRefresh && !hasNext) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
final resp = await DramaService.fetchFeed(
|
||||
size: 20, requestId: _requestId ??= const Uuid().v4());
|
||||
if (resp != null) _requestId = null; // 成功才作废;失败重试沿用同一个 id,避免服务端当成两批
|
||||
//拿到什么收什么,不按剧 id 去重:服务端反复下发同一部剧时,去重会让列表不增长,
|
||||
//用户停在最后一条划不动、onPageChanged 也不再触发,就彻底卡死了。
|
||||
//宁可刷到重复的剧,也不能卡住——重复的 key 由页面用「剧 id + 下标」避开
|
||||
final more = <VideoModel>[];
|
||||
for (final item in resp?.list ?? const <DramaFeedItem>[]) {
|
||||
final media = item.media;
|
||||
if (media == null) continue; // 缺 media 的脏数据,别在信息流里留个空位
|
||||
more.add(media.toVideoModel(item.content));
|
||||
}
|
||||
hasNext = resp?.hasNext ?? false;
|
||||
list = isRefresh ? more : [...?list, ...more];
|
||||
} catch (e) {
|
||||
list ??= [];
|
||||
debugLog('短剧信息流获取失败:$e');
|
||||
}
|
||||
isLoading = false;
|
||||
isFirstLoaded = true;
|
||||
update();
|
||||
}
|
||||
|
||||
void onPageChanged(int index) {
|
||||
if (pageIndex != index) {
|
||||
pageIndex = index;
|
||||
update([idFeed]); // 只刷信息流,各 item 重算 isCurrentPage
|
||||
}
|
||||
// 快到底了先把下一批拉上,别等滑到最后一条才请求
|
||||
if (hasNext && (list?.length ?? 0) - index <= 6) loadData(isRefresh: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_feed_logic.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_video_player.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
|
||||
/// AI短剧信息流页:竖划一部换一部,每条播该剧第 1 集
|
||||
class DramaFeedPage extends StatefulWidget {
|
||||
final TabController? superTabCtr;
|
||||
final int? tabIndex;
|
||||
|
||||
const DramaFeedPage({super.key, this.superTabCtr, this.tabIndex});
|
||||
|
||||
@override
|
||||
State<DramaFeedPage> createState() => _DramaFeedPageState();
|
||||
}
|
||||
|
||||
class _DramaFeedPageState extends State<DramaFeedPage> with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<DramaFeedLogic>(
|
||||
tag: uniqueTag, // 多 tab 并存,按实例隔离
|
||||
init: DramaFeedLogic(
|
||||
superTabCtr: widget.superTabCtr, tabIndex: widget.tabIndex),
|
||||
builder: (logic) {
|
||||
final Widget content;
|
||||
if (!logic.isFirstLoaded) {
|
||||
content = const LoadingCenterWidget();
|
||||
} else if (logic.list?.isNotEmpty != true) {
|
||||
content = CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
} else {
|
||||
content = _buildFeed(logic);
|
||||
}
|
||||
return Material(color: Colors.transparent, child: content);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 信息流局部刷新:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage
|
||||
Widget _buildFeed(DramaFeedLogic logic) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => logic.loadData(),
|
||||
child: GetBuilder<DramaFeedLogic>(
|
||||
tag: uniqueTag,
|
||||
id: DramaFeedLogic.idFeed,
|
||||
// 上下滑动开始/结束 → 切换 isScrolling,各播放器据此隐藏/显示进度条
|
||||
builder: (logic) => NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n is ScrollStartNotification) {
|
||||
logic.isScrolling.value = true;
|
||||
} else if (n is ScrollEndNotification) {
|
||||
logic.isScrolling.value = false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: PageView.builder(
|
||||
onPageChanged: logic.onPageChanged,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
controller: logic.pageCtr,
|
||||
scrollDirection: Axis.vertical,
|
||||
allowImplicitScrolling: true,
|
||||
itemCount: logic.list?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final video = logic.list![index];
|
||||
//推荐流不去重,同一部剧会反复下发,光用剧 id 会撞 key(PageView 直接抛 Duplicate keys);
|
||||
//带上下标才唯一。也不能只用下标——下拉刷新后同一个位置换了别的剧,
|
||||
//key 不变 PageView 就会复用 State,播放器不重建,播的还是上一部
|
||||
return DramaVideoPlayer(
|
||||
key: ValueKey('${video.dramaInfo?.id ?? ''}#$index'),
|
||||
index: index,
|
||||
isCurrentPage: logic.pageIndex == index,
|
||||
videoInfo: video,
|
||||
isScrolling: logic.isScrolling,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/tools_base/base_list_controller.dart';
|
||||
|
||||
/// 标签页本地排序(专题接口仅用于热门短剧橱窗)
|
||||
const _tagSortTabs = [
|
||||
SortTab('热门推荐', 1),
|
||||
SortTab('最新上架', 2),
|
||||
SortTab('最多收藏', 4),
|
||||
];
|
||||
|
||||
/// 专题接口失败时的兜底(与文档系统专题对齐)
|
||||
final _fallbackTopics = [
|
||||
DramaTopic(
|
||||
topicId: 'hot_recommend',
|
||||
name: '热门推荐',
|
||||
topicType: 'SYSTEM',
|
||||
systemKey: 'HOT_RECOMMEND',
|
||||
sort: 500,
|
||||
),
|
||||
DramaTopic(
|
||||
topicId: 'latest',
|
||||
name: '最新上架',
|
||||
topicType: 'SYSTEM',
|
||||
systemKey: 'LATEST',
|
||||
sort: 400,
|
||||
),
|
||||
DramaTopic(
|
||||
topicId: 'everyone_likes',
|
||||
name: '大家爱看',
|
||||
topicType: 'SYSTEM',
|
||||
systemKey: 'EVERYONE_LIKES',
|
||||
sort: 300,
|
||||
),
|
||||
];
|
||||
|
||||
class DramaListLogic extends ListBaseLogic<VideoModel> {
|
||||
/// 传了就是标签页:只出这个标签下的剧,改走片库搜索;不传是短剧频道里的热门橱窗
|
||||
final String? tagId;
|
||||
|
||||
DramaListLogic({this.tagId});
|
||||
|
||||
int sortIndex = 0;
|
||||
|
||||
/// 热门短剧橱窗专题 Tab(接口下发)
|
||||
List<DramaTopic> topics = [];
|
||||
bool topicsReady = false;
|
||||
|
||||
bool get _isTag => tagId?.isNotEmpty == true;
|
||||
|
||||
List<String> get sortTitles {
|
||||
if (_isTag) return _tagSortTabs.map((e) => e.name).toList();
|
||||
return topics.map((e) => e.name ?? '').where((e) => e.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
String? get _currentTopicId {
|
||||
if (_isTag || topics.isEmpty) return null;
|
||||
if (sortIndex < 0 || sortIndex >= topics.length) return null;
|
||||
final id = topics[sortIndex].topicId;
|
||||
return id?.isNotEmpty == true ? id : null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isLoading =>
|
||||
_isTag ? super.isLoading : (!topicsReady || super.isLoading);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
if (_isTag) {
|
||||
topicsReady = true;
|
||||
loadData();
|
||||
} else {
|
||||
_loadTopicsThenList();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTopicsThenList() async {
|
||||
try {
|
||||
final list = await DramaService.fetchTopics();
|
||||
topics = (list == null || list.isEmpty) ? List.of(_fallbackTopics) : list;
|
||||
} catch (_) {
|
||||
topics = List.of(_fallbackTopics);
|
||||
}
|
||||
topicsReady = true;
|
||||
sortIndex = 0;
|
||||
update();
|
||||
await loadData();
|
||||
}
|
||||
|
||||
//isRefresh 下拉刷新/加载更多;showLoading 切换排序时先清空显示 loading
|
||||
//返回 Future:CErrorWidget 的重试按钮靠返回值出 loading 和防连点
|
||||
Future<void> loadData({bool isRefresh = true, bool showLoading = false}) {
|
||||
if (!_isTag && !topicsReady) return Future.value();
|
||||
if (showLoading) {
|
||||
dataList = null;
|
||||
update();
|
||||
}
|
||||
return fetchData(isRefresh: isRefresh, fetch: _fetch);
|
||||
}
|
||||
|
||||
//两个接口返回的都是 ListBaseModel<DramaMediaInfo>,取完统一转 VideoModel:
|
||||
//卡片复用 VideoSimpleCell,一次性转好,滚动时不再反复构造
|
||||
Future<(List<VideoModel>?, bool)> _fetch(int page) async {
|
||||
if (_isTag) {
|
||||
final sort = _tagSortTabs[sortIndex].sort;
|
||||
final res = await DramaService.searchLibrary(
|
||||
page: page,
|
||||
size: 20,
|
||||
tagIds: [tagId!],
|
||||
sortType: sort,
|
||||
);
|
||||
return (
|
||||
res?.list?.map((e) => e.toVideoModel(null)).toList(),
|
||||
res?.hasNext ?? false
|
||||
);
|
||||
}
|
||||
|
||||
final topicId = _currentTopicId;
|
||||
if (topicId == null) return (<VideoModel>[], false);
|
||||
final res =
|
||||
await DramaService.fetchTopicWorks(topicId, page: page, size: 20);
|
||||
return (
|
||||
res?.list?.map((e) => e.toVideoModel(null)).toList(),
|
||||
res?.hasNext ?? false
|
||||
);
|
||||
}
|
||||
|
||||
void changeSort(int index) {
|
||||
if (sortIndex == index) return;
|
||||
if (_isTag) {
|
||||
if (index < 0 || index >= _tagSortTabs.length) return;
|
||||
} else {
|
||||
if (index < 0 || index >= topics.length) return;
|
||||
}
|
||||
sortIndex = index;
|
||||
loadData(showLoading: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_detail_page.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_list_logic.dart';
|
||||
import 'package:hgdj/hj_page/home/home_cell_style/video_simple_cell.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
|
||||
/// 排序 tab + 双列剧集宫格。两种用法:
|
||||
/// 不传 [tag] = 短剧频道里的「热门短剧」橱窗(嵌在 tab 里,无标题栏);
|
||||
/// 传 [tag] = 短剧标签页(独立路由、带标题栏,只出该标签下的剧)
|
||||
class DramaListPage extends StatefulWidget {
|
||||
final TagsBean? tag;
|
||||
|
||||
const DramaListPage({super.key, this.tag});
|
||||
|
||||
//标签页是独立路由,靠这个名字区分
|
||||
static const tagRoute = '/DramaTagPage';
|
||||
|
||||
/// 进短剧标签页。跳转一律走它,别在调用方自己写 Get.to——不显式传 routeName 时
|
||||
/// GetX 会用闭包的 runtimeType 拼出 `/DramaListPage Function()` 作为路由名
|
||||
static Future<T?>? toTag<T>(TagsBean tag) =>
|
||||
Get.to(() => DramaListPage(tag: tag),
|
||||
routeName: tagRoute, preventDuplicates: true);
|
||||
|
||||
@override
|
||||
State<DramaListPage> createState() => _DramaListPageState();
|
||||
}
|
||||
|
||||
class _DramaListPageState extends State<DramaListPage> with UniqueTagMixin {
|
||||
/// 排序 Tab 超过 4 个时是否展开为多行;默认折叠只显示第一行
|
||||
bool _sortExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<DramaListLogic>(
|
||||
init: DramaListLogic(tagId: widget.tag?.id),
|
||||
tag: uniqueTag,
|
||||
builder: (logic) {
|
||||
final content = ColoredBox(
|
||||
color: const Color(0xFF05000E),
|
||||
child: Column(
|
||||
children: [
|
||||
widget.tag == null
|
||||
? _buildSortTabBar(logic)
|
||||
: _buildTagSortTabBar(logic),
|
||||
Expanded(child: _buildBody(logic)),
|
||||
],
|
||||
),
|
||||
);
|
||||
//橱窗是嵌在短剧频道 tab 里的,外面已经有页面壳,别再套一层 Scaffold
|
||||
if (widget.tag == null) return content;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.tag?.name ?? '')),
|
||||
body: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 标签页排序:无底色,选中白字,未选中 #59FFFFFF,中间 12 高竖线
|
||||
Widget _buildTagSortTabBar(DramaListLogic logic) {
|
||||
final titles = logic.sortTitles;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < titles.length; i++) ...[
|
||||
if (i > 0)
|
||||
Container(
|
||||
width: 1,
|
||||
height: 12,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 18),
|
||||
color: const Color(0x1AFFFFFF),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => logic.changeSort(i),
|
||||
child: Text(
|
||||
titles[i],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: i == logic.sortIndex
|
||||
? Colors.white
|
||||
: const Color(0x59FFFFFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 「热门短剧」下排序 Tab:72×26 胶囊;居左;一行最多 4 个,超出可展开/收起
|
||||
Widget _buildSortTabBar(DramaListLogic logic) {
|
||||
final titles = logic.sortTitles;
|
||||
final needToggle = titles.length > 4;
|
||||
final visibleCount = (!needToggle || _sortExpanded) ? titles.length : 4;
|
||||
|
||||
// 按每行 4 个切行
|
||||
final rows = <List<int>>[];
|
||||
for (var i = 0; i < visibleCount; i += 4) {
|
||||
final end = i + 4 > visibleCount ? visibleCount : i + 4;
|
||||
rows.add([for (var j = i; j < end; j++) j]);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var r = 0; r < rows.length; r++) ...[
|
||||
if (r > 0) const SizedBox(height: 8),
|
||||
_buildSortRow(logic, titles, rows[r],
|
||||
hasToggle: _rowHasToggle(needToggle, rows, r)),
|
||||
],
|
||||
// 展开且末行已满 4 个时,收起按钮另起一行跟在最后
|
||||
if (needToggle &&
|
||||
_sortExpanded &&
|
||||
rows.isNotEmpty &&
|
||||
rows.last.length == 4) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [_buildExpandToggle()]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _rowHasToggle(bool needToggle, List<List<int>> rows, int r) {
|
||||
if (!needToggle) return false;
|
||||
if (!_sortExpanded) return r == 0;
|
||||
return r == rows.length - 1 && rows[r].length < 4;
|
||||
}
|
||||
|
||||
Widget _buildSortRow(
|
||||
DramaListLogic logic,
|
||||
List<String> titles,
|
||||
List<int> indexes, {
|
||||
required bool hasToggle,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
for (var k = 0; k < indexes.length; k++) ...[
|
||||
if (k > 0) const SizedBox(width: 8),
|
||||
_buildSortTab(logic, indexes[k], titles[indexes[k]],
|
||||
expand: !hasToggle),
|
||||
],
|
||||
if (hasToggle) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildExpandToggle(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortTab(DramaListLogic logic, int index, String title,
|
||||
{bool expand = false}) {
|
||||
final selected = index == logic.sortIndex;
|
||||
final tab = GestureDetector(
|
||||
onTap: () => logic.changeSort(index),
|
||||
child: Container(
|
||||
width: expand ? double.infinity : 72,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFFF68804) : const Color(0x0DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? FontWeight.w500 : FontWeight.w400,
|
||||
color: selected ? Colors.white : const Color(0x8CFFFFFF),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return expand ? Expanded(child: tab) : tab;
|
||||
}
|
||||
|
||||
Widget _buildExpandToggle() {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _sortExpanded = !_sortExpanded),
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: AnimatedRotation(
|
||||
turns: _sortExpanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Image.asset('chevron_down.webp'.commonImgPath,
|
||||
width: 16, height: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 进短剧二级页
|
||||
void _openDrama(VideoModel model) {
|
||||
//不指定集数:有续播位置就从上次看到的那一集接着播
|
||||
Get.to(
|
||||
() => DramaDetailPage(drama: model.dramaInfo),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(DramaListLogic logic) {
|
||||
if (logic.isLoading) return const LoadingCenterWidget();
|
||||
if (logic.isEmptyData)
|
||||
return CErrorWidget(retryOnTap: () => logic.loadData());
|
||||
final list = logic.dataList!;
|
||||
return pullYsRefresh(
|
||||
onInit: (ctr) => logic.refreshCtr = ctr,
|
||||
onRefresh: (_) => logic.loadData(),
|
||||
onLoading: (_) => logic.loadData(isRefresh: false),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisSpacing: 6,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 168 / 266,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (context, index) {
|
||||
final VideoModel model = list[index];
|
||||
return VideoSimpleCell(
|
||||
videoModel: model,
|
||||
textLines: 1,
|
||||
coverRightText: model.dramaInfo?.episodeNumberStatus,
|
||||
showLevelIcon: false,
|
||||
onTap: () => _openDrama(model),
|
||||
onTagTap: (tag) => DramaListPage.toTag(tag),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
/// 短剧底部条:信息流「查看完整短剧」入口 / 二级页「观看完整剧集」选集入口 + 自动播放倒计时。
|
||||
/// 不跟左右菜单一起收:纯净模式(眼睛按钮)下也常驻,这是进整部剧的唯一入口
|
||||
class DramaBottomBar extends StatelessWidget {
|
||||
final String? leadingIcon; // 信息流左侧的剧集图标(资源名),二级页不传
|
||||
final String? highlight; // 橙色高亮前缀,如「5s后」
|
||||
final String text;
|
||||
final String tailIcon; // 资源名:二级页向上展开选集,信息流向右跳详情
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const DramaBottomBar({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.leadingIcon,
|
||||
this.highlight,
|
||||
this.tailIcon = 'chevron_up.webp',
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)),
|
||||
//半透明底 + 背景模糊,纯色压不出那个质感。
|
||||
//模糊范围被外层圆角裁住,只糊药丸这一小块,不会整帧走离屏
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
child: Container(
|
||||
//高度写死 36(设计稿):不能让「有没有左图标」「字号多大」把它撑变——
|
||||
//二级页按 DramaDetailPage._barHeight 给它预留空间,浮动就会和进度条挤到一起
|
||||
height: 36,
|
||||
color: const Color(0xff2A2A2A).withValues(alpha: 0.5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leadingIcon != null) ...[
|
||||
//设计稿图标框 24,图形自带 4px 透明留白,所以与文字的视觉间距只有 10
|
||||
Image.asset(leadingIcon!.videoPath, width: 24, height: 24),
|
||||
10.sizeBoxW,
|
||||
],
|
||||
Expanded(
|
||||
child: EasyRichText(
|
||||
'${highlight ?? ''}$text',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
defaultStyle:
|
||||
const TextStyle(color: Colors.white, fontSize: 13),
|
||||
patternList: [
|
||||
//highlight 是「5s后」这种自己拼的倒计时,不会有正则元字符
|
||||
if (highlight?.isNotEmpty == true)
|
||||
EasyRichTextPattern(
|
||||
targetString: highlight,
|
||||
matchOption: 'first',
|
||||
style: const TextStyle(color: AppColors.actionRed),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Image.asset(tailIcon.videoPath, width: 18, height: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_model/drama_media_info.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_page/live/live_widget.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
|
||||
/// 短剧选集面板:剧名 + 连载状态 + 分段(1-30/31-60…) + 集数宫格
|
||||
/// 选中某集后 Get.back(result: 该集下标)
|
||||
class DramaEpisodeSheet extends StatefulWidget {
|
||||
final DramaMediaInfo? drama; // 剧信息(剧名/连载状态/总集数)
|
||||
final List<MediaContent> episodes;
|
||||
final int selectedIndex;
|
||||
|
||||
/// 翻到未加载的分段时续拉下一页
|
||||
final Future<void> Function()? onLoadMore;
|
||||
|
||||
const DramaEpisodeSheet({
|
||||
super.key,
|
||||
this.drama,
|
||||
required this.episodes,
|
||||
this.selectedIndex = 0,
|
||||
this.onLoadMore,
|
||||
});
|
||||
|
||||
static Future<int?> show({
|
||||
DramaMediaInfo? drama,
|
||||
required List<MediaContent> episodes,
|
||||
int selectedIndex = 0,
|
||||
Future<void> Function()? onLoadMore,
|
||||
}) {
|
||||
return Get.bottomSheet<int>(
|
||||
DramaEpisodeSheet(
|
||||
drama: drama,
|
||||
episodes: episodes,
|
||||
selectedIndex: selectedIndex,
|
||||
onLoadMore: onLoadMore,
|
||||
),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<DramaEpisodeSheet> createState() => _DramaEpisodeSheetState();
|
||||
}
|
||||
|
||||
class _DramaEpisodeSheetState extends State<DramaEpisodeSheet> {
|
||||
//每段集数,与接口分页 DramaMediaInfo.episodePageSize 一致:第 i 页正好是第 i 段
|
||||
static const _segmentSize = 30;
|
||||
|
||||
late int segmentIndex = widget.selectedIndex ~/ _segmentSize; // 默认定位到当前集所在段
|
||||
|
||||
bool _isLoading = false; // 正在补拉目标段的数据
|
||||
|
||||
//分段要按**总集数**分,不能按已加载数:只拉了首页就只显示一段,用户根本不知道后面还有
|
||||
int get _total => widget.drama?.totalEpisode ?? widget.episodes.length;
|
||||
|
||||
//「连载中」「已完结」,后端下发
|
||||
String get _updateDesc => widget.drama?.updateDesc ?? '';
|
||||
|
||||
int get segmentCount => (_total / _segmentSize).ceil();
|
||||
|
||||
//当前段对应的集数区间(下标)。上限取已加载数:翻到还没拉回来的段时 _end 会小于 _start,
|
||||
//由 _episodeGrid 按「本段为空」处理(不能用 clamp 夹,lower>upper 会抛 ArgumentError)
|
||||
int get _start => segmentIndex * _segmentSize;
|
||||
|
||||
int get _end {
|
||||
final end = _start + _segmentSize;
|
||||
return end > widget.episodes.length ? widget.episodes.length : end;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: Get.height * 0.63,
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 18),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(child: SheetHandleBar()),
|
||||
18.sizeBoxH,
|
||||
// 剧名
|
||||
Text(
|
||||
widget.drama?.title ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
// 连载中·全x集
|
||||
EasyRichText(
|
||||
'$_updateDesc·共$_total集',
|
||||
defaultStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.35),
|
||||
fontSize: 12,
|
||||
height: 1.5),
|
||||
patternList: [
|
||||
//updateDesc 是后端下发的、可能带正则元字符,必须开 hasSpecialCharacters 转义;
|
||||
//空串更要拦住——空 targetString 会编译成 (),在每个位置都命中
|
||||
if (_updateDesc.isNotEmpty)
|
||||
EasyRichTextPattern(
|
||||
targetString: _updateDesc,
|
||||
matchOption: 'first',
|
||||
hasSpecialCharacters: true,
|
||||
style: const TextStyle(color: AppColors.actionRed),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
if (segmentCount > 1) ...[
|
||||
_segmentBar(),
|
||||
18.sizeBoxH,
|
||||
],
|
||||
Expanded(child: _episodeGrid()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分段切换:1-30 / 31-60 …
|
||||
Widget _segmentBar() {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (i) {
|
||||
final from = i * _segmentSize + 1;
|
||||
final to = ((i + 1) * _segmentSize).clamp(0, _total);
|
||||
final isSelected = i == segmentIndex;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => _selectSegment(i),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: i == segmentCount - 1 ? 0 : 24),
|
||||
child: Text(
|
||||
'$from-$to',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Colors.white.withValues(alpha: isSelected ? 0.9 : 0.35),
|
||||
fontSize: 14,
|
||||
height: 1.43,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 切段:目标段的数据没拉够就一直补,直到够了或后端没有更多。
|
||||
/// 与 logic 共用同一个 List 实例,拉完刷新自己就能看到新集
|
||||
Future<void> _selectSegment(int index) async {
|
||||
setState(() => segmentIndex = index);
|
||||
final need = (index + 1) * _segmentSize;
|
||||
if (_isLoading || widget.episodes.length >= need) return;
|
||||
setState(() => _isLoading = true);
|
||||
while (widget.episodes.length < need) {
|
||||
final before = widget.episodes.length;
|
||||
await widget.onLoadMore?.call();
|
||||
if (!mounted) return;
|
||||
if (widget.episodes.length == before) break; // 一集没多,说明到底了
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
/// accessType 是本集的「解锁门槛/状态」:free 免费 / coin 需金币 / card 需短剧卡 / bought 已买过
|
||||
Widget? _badgeOf(MediaContent model) {
|
||||
switch (model.accessType) {
|
||||
case 'coin':
|
||||
return _corner('金币', color: const Color(0xffFF9000));
|
||||
case 'bought':
|
||||
return _corner('已购');
|
||||
default: // free / card 及未知类型不挂角标
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 格子左上角贴边角标。短剧的样式跟 ACG 的 FreeBadge 不一样,各画各的不共用;
|
||||
/// 不传 color 就是已解锁那支红渐变(已购/短剧卡同款)
|
||||
Widget _corner(String text, {Color? color}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
gradient: color != null
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.bottomRight,
|
||||
end: Alignment.topLeft,
|
||||
colors: [Color(0xffFF4D4D), Color(0xffFF6E6E)],
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(6), bottomRight: Radius.circular(9)),
|
||||
),
|
||||
child: Text(text,
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 10, height: 1.6)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodeGrid() {
|
||||
final count = _end - _start;
|
||||
//本段还没拉回来:转圈;拉完了还是空说明这段确实没有(后端集数对不上)
|
||||
if (count <= 0) {
|
||||
return Center(
|
||||
child: _isLoading
|
||||
? const CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed, radius: 10)
|
||||
: Text('暂无剧集',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.35), fontSize: 12)),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
crossAxisSpacing: 13,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: count,
|
||||
itemBuilder: (context, index) {
|
||||
final realIndex = _start + index;
|
||||
return _episodeItem(realIndex);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodeItem(int index) {
|
||||
final model = widget.episodes[index];
|
||||
final isSelected = index == widget.selectedIndex;
|
||||
//角标只区分金币/已购;免费和短剧卡不挂标(设计稿要求)
|
||||
final badge = _badgeOf(model);
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: index),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0x0DF68804) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppColors.actionRed
|
||||
: Colors.white.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (badge != null) Positioned(top: 0, left: 0, child: badge),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${model.episodeNumber ?? index + 1}',
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? AppColors.actionRed
|
||||
: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: isSelected ? 14 : 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
2.sizeBoxW,
|
||||
const AudioWaveView(color: AppColors.actionRed, height: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_detail_page.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_list_page.dart';
|
||||
import 'package:hgdj/hj_page/short_video/view/media_action_menu.dart';
|
||||
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../tools_base/widget/shrink_wrap.dart';
|
||||
|
||||
/// 短剧操作台:左侧「剧名 + 第N集 + 标签」+ 右侧动作菜单 + 中间暂停播放按钮。
|
||||
/// 点赞/收藏/评论/分享全部挂在**剧**上,下载则是当前这一集;短剧没有作者人设
|
||||
class DramaMenu extends StatefulWidget {
|
||||
final VideoPlayerController? playerCtr;
|
||||
final VideoModel? videoModel;
|
||||
final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播
|
||||
final bool showEntry; //剧名是否带箭头可点进二级页;二级页自身传 false,否则会重复入栈
|
||||
|
||||
/// 信息栏底部要距播放器底多远。由播放器按「进度条 + 剧集条 + 底部安全区」算好传进来——
|
||||
/// 写死过一次(40+48),二级页在带手势条的机型上安全区一顶就和进度条压到一起了
|
||||
final double bottomInset;
|
||||
|
||||
const DramaMenu({
|
||||
super.key,
|
||||
this.playerCtr,
|
||||
this.videoModel,
|
||||
this.onSwitchLine,
|
||||
this.showEntry = true,
|
||||
required this.bottomInset,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DramaMenu> createState() => _DramaMenuState();
|
||||
}
|
||||
|
||||
class _DramaMenuState extends State<DramaMenu> {
|
||||
//信息栏各行的上间距(设计稿 剧名→标签行 12)
|
||||
double get gapValue => 12;
|
||||
|
||||
VideoModel? get _video => widget.videoModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
VideoMenuSwitch.addListen(_menuListen);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VideoMenuSwitch.removeLister(_menuListen);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _menuListen(bool isShow) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
//外层那个 bottom:4 已经占掉一部分,这里补齐到调用方要求的总距离
|
||||
padding: EdgeInsets.only(bottom: widget.bottomInset - 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MenuReveal(
|
||||
visible: VideoMenuSwitch.isShow,
|
||||
offset: const Offset(-0.1, 0), // 隐藏时向左滑出
|
||||
child: _buildLeftPanel(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
MediaActionMenu(
|
||||
style: MediaStyle.Drama,
|
||||
videoModel: _video,
|
||||
playerCtr: widget.playerCtr,
|
||||
onSwitchLine: widget.onSwitchLine,
|
||||
),
|
||||
],
|
||||
),
|
||||
12.sizeBoxW,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.playerCtr != null)
|
||||
CenterPlayPause(controller: widget.playerCtr!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 左侧信息栏(剧名 / 第N集 + 标签)============
|
||||
|
||||
Widget _buildLeftPanel() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
//剧名,二级页里不带箭头也不可点
|
||||
_buildTitleItem(),
|
||||
//第N集 + 标签
|
||||
_buildTagItem(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleItem() {
|
||||
final title = _video?.title?.trim() ?? '';
|
||||
if (title.isEmpty) return const SizedBox();
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
if (widget.showEntry)
|
||||
Image.asset('chevron_right.webp'.videoPath, width: 20, height: 20),
|
||||
],
|
||||
);
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: gapValue),
|
||||
child: widget.showEntry
|
||||
? InkWell(enableFeedback: false, onTap: _openDrama, child: content)
|
||||
: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagItem() {
|
||||
final episodeText = Text(
|
||||
"第${_video?.episodeNo ?? 1}集",
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500),
|
||||
);
|
||||
if (_video?.tags?.isNotEmpty != true) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: gapValue), child: episodeText);
|
||||
}
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: gapValue),
|
||||
child: Row(
|
||||
children: [
|
||||
episodeText,
|
||||
6.sizeBoxW,
|
||||
Expanded(child: _tagWrap()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagWrap() {
|
||||
return ShrinkWrap(
|
||||
spacing: 0,
|
||||
runSpacing: 6,
|
||||
maxLines: 1,
|
||||
children: _video?.tags
|
||||
?.map((e) => InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
widget.playerCtr?.pause();
|
||||
//短剧标签要出的是「剧」,不能用视频标签页——那边的卡片点开跳的是视频详情页
|
||||
DramaListPage.toTag(e);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0DFFFFFF),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(e.name ?? '',
|
||||
style: const TextStyle(
|
||||
color: Color(0xffffffff), fontSize: 11)),
|
||||
),
|
||||
))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/// 进短剧二级页,定位到当前这一集。只有刷剧模式挂得上(二级页 showEntry=false)
|
||||
void _openDrama() {
|
||||
widget.playerCtr?.pause();
|
||||
final index = (_video?.episodeNo ?? 1) - 1;
|
||||
//把这一刻的集数+秒数带进连播模式,写法与 DramaVideoPlayer._openDrama 一致
|
||||
Get.to(
|
||||
() => DramaDetailPage(
|
||||
drama: _video?.dramaInfo,
|
||||
initialIndex: index < 0 ? 0 : index,
|
||||
initialSeconds: widget.playerCtr?.value.position.inSeconds ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import 'package:easy_rich_text/easy_rich_text.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/alert/video/buy_vip_alert.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_model/drama/drama_models.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart';
|
||||
import 'package:hgdj/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart';
|
||||
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/net_code.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 短剧付费墙:只有「金币解锁」和「开通短剧卡」两个按钮,没有第三种选择。
|
||||
/// 标题/价格/按钮文案一律用服务端下发的 [DramaPaywall],客户端不自己拼。
|
||||
class DramaPaywallView extends StatefulWidget {
|
||||
final VideoModel? video;
|
||||
|
||||
/// 解锁成功(金币解锁 / 开卡后已放行):调用方重新拉分集详情并续播。
|
||||
/// [byCard] true = 走的开短剧卡,整部剧一起放行,不只是当前这一集
|
||||
final Future<void> Function({bool byCard}) onUnlocked;
|
||||
|
||||
const DramaPaywallView({
|
||||
super.key,
|
||||
required this.video,
|
||||
required this.onUnlocked,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DramaPaywallView> createState() => _DramaPaywallViewState();
|
||||
}
|
||||
|
||||
class _DramaPaywallViewState extends State<DramaPaywallView> {
|
||||
bool _busy = false; // 解锁/开卡进行中,防连点
|
||||
|
||||
/// 已经解锁成功、正在等调用方拉分集详情并把播放器建起来。
|
||||
/// 那段有网络往返 + initialize,好几秒,按钮还杵在那儿用户会以为没成功接着点
|
||||
bool _unlocking = false;
|
||||
|
||||
/// 本次解锁的幂等键:失败保留、成功清空。同一次解锁的重试(含充值后自动重试)复用它,
|
||||
/// 服务端直接回首次结果,不会重复扣金币
|
||||
String? _requestId;
|
||||
|
||||
String? get _mediaId => widget.video?.dramaInfo?.id;
|
||||
|
||||
String? get _contentId => widget.video?.dramaEpisode?.id;
|
||||
|
||||
DramaPaywall? get _paywall => widget.video?.dramaEpisode?.paywall;
|
||||
|
||||
//钱包给的最新余额,顶掉付费墙里那份快照
|
||||
int? _newBalance;
|
||||
|
||||
//进墙那一刻的快照打底,之后谁刷了钱包就跟谁:只认快照的话,
|
||||
//切去「我的」充完金币再切回来还是旧数,够钱也会被当成余额不足。
|
||||
//反过来钱包要「我的」等页面才会去拉,冷启动直奔短剧时是 null,不能拿它兜底
|
||||
int get _balance => _newBalance ?? _paywall?.coinBalance ?? 0;
|
||||
|
||||
String get _coin => '$_balance';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
globalStore.addListener(_onWalletChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
globalStore.removeListener(_onWalletChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
//别处充值/花钱都会 refreshWallet 并通知,跟着把墙上的数顶掉
|
||||
void _onWalletChanged() {
|
||||
final amount = globalStore.wallet?.amount;
|
||||
if (amount == null || amount == _balance) return;
|
||||
setState(() => _newBalance = amount);
|
||||
}
|
||||
|
||||
PayOrderTrackInfo get _orderTrack => PayOrderTrackInfo(
|
||||
sourcePage: PaySourcePage.dramaPaywall,
|
||||
//sourceRef/videoId 与其它入口同口径:来源那条内容的 id,短剧就是剧 id
|
||||
sourceRef: _mediaId,
|
||||
videoId: _mediaId,
|
||||
mediaId: _mediaId,
|
||||
contentId: _contentId,
|
||||
checkoutContextId: _paywall?.checkoutContextId,
|
||||
);
|
||||
|
||||
/// 金币解锁:余额够直接下单;不够先弹充值,充完自动再解锁一次(复用同一个 checkoutContextId)
|
||||
Future<void> _onCoinUnlock() async {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
final need = _paywall?.unlockCoin ?? 0;
|
||||
if (_balance < need) {
|
||||
//看着不够先跟钱包对一次:手上这份可能是几分钟前进墙时的快照,
|
||||
//直接信它会把已经在别处充过钱的人又推去充值
|
||||
await globalStore.refreshWallet();
|
||||
if (!mounted) return;
|
||||
}
|
||||
if (_balance < need) {
|
||||
await CoinPayBottomSheet.show(
|
||||
sourcePage: PaySourcePage.dramaPaywall, orderTrack: _orderTrack);
|
||||
await globalStore.refreshWallet();
|
||||
if (!mounted || _balance < need) return; // 没充成功,停在付费墙
|
||||
}
|
||||
await _unlock();
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// [allowRetry] 只允许「充值成功后自动再解锁一次」,避免服务端一直回 8000 时来回打。
|
||||
/// loading / 8000 提示 / 8005 当成功都在 PayManager 里,这里只管短剧自己的收尾
|
||||
Future<void> _unlock({bool allowRetry = true}) async {
|
||||
final requestId = _requestId ??= const Uuid().v4();
|
||||
//PayManager 的回调是同步签名,把后续异步动作接出来再 await,
|
||||
//否则 _onCoinUnlock 的 finally 会在解锁真正走完前就把 _busy 放开
|
||||
Future<void>? next;
|
||||
await PayManager().buy(
|
||||
_mediaId,
|
||||
ProductType.mediaAll, // 短剧买单集:productID 是剧 id,具体哪一集看 contentID
|
||||
contentID: _contentId,
|
||||
checkoutContextId: _paywall?.checkoutContextId,
|
||||
requestId: requestId,
|
||||
source: 'drama_unlock',
|
||||
jsonTransformation: (json) => DramaUnlockResult.fromJson(json),
|
||||
//余额不足要就地弹金币支付,跳充值页会把播放器和当前集一起丢掉
|
||||
jumpWalletOnInsufficient: false,
|
||||
onSuccess: (resp) {
|
||||
_requestId = null;
|
||||
// 服务端扣完的余额直接写回,再拉一次钱包对齐(与下载授权同一套写法)。
|
||||
// 8005 走到这里时 data 是错误体不是订单结果,所以要判类型
|
||||
final unlock = resp.data;
|
||||
if (unlock is DramaUnlockResult && unlock.coinBalance != null) {
|
||||
_newBalance = unlock.coinBalance;
|
||||
globalStore.wallet?.amount = unlock.coinBalance;
|
||||
}
|
||||
globalStore.refreshWallet();
|
||||
setState(() => _unlocking = true);
|
||||
next = widget.onUnlocked().whenComplete(_stopUnlocking);
|
||||
},
|
||||
onFailure: (resp) => next = _onUnlockFailed(resp, allowRetry: allowRetry),
|
||||
);
|
||||
await next;
|
||||
}
|
||||
|
||||
/// 解锁失败:只处理余额不足,其余保留 _requestId,重试复用同一个键别重复扣费
|
||||
Future<void> _onUnlockFailed(BaseRespBean? resp,
|
||||
{required bool allowRetry}) async {
|
||||
if (resp?.code != Code.NOT_ENOUGH_MONEY) return;
|
||||
await CoinPayBottomSheet.show(
|
||||
sourcePage: PaySourcePage.dramaPaywall, orderTrack: _orderTrack);
|
||||
_requestId = null; // 充值后是新的一次解锁,换新键;沿用旧键会拿回刚才那次「余额不足」
|
||||
await globalStore.refreshWallet();
|
||||
if (!mounted) return;
|
||||
if (allowRetry && _balance >= (_paywall?.unlockCoin ?? 0)) {
|
||||
await _unlock(allowRetry: false); // 充值成功,自动补一次解锁
|
||||
}
|
||||
}
|
||||
|
||||
/// 开通短剧卡:和长视频共用同一个会员弹窗,默认选中 ping 下发的 [Config.shortDramaCardId]。
|
||||
/// 关掉后不判有没有买,直接交给 onUnlocked 重拉分集详情看服务端放没放行——
|
||||
/// _loadEpisode 本来就按 canPlay 决定撤墙还是继续挂着,没买成也只是转一下圈退回付费墙
|
||||
Future<void> _onCardTap() async {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await BuyVipAlert.show(
|
||||
vipId: Config.shortDramaCardId, orderTrack: _orderTrack);
|
||||
if (!mounted) return;
|
||||
setState(() => _unlocking = true);
|
||||
await widget.onUnlocked(byCard: true).whenComplete(_stopUnlocking);
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端没放行(开卡没买成 / 解锁后仍 canPlay=false)时墙会留着,得把按钮还回来,
|
||||
/// 否则只剩一个转不完的圈,连重试入口都没有
|
||||
void _stopUnlocking() {
|
||||
if (mounted) setState(() => _unlocking = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final paywall = _paywall;
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
color: const Color.fromRGBO(0, 7, 18, 0.8),
|
||||
//已经付过钱了,别再让人对着解锁按钮干等:这层遮罩留着盖住还没建好的播放器
|
||||
child: _unlocking
|
||||
? const CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed, radius: 15)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
paywall?.titleUI ?? '更多精彩解锁即享',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
18.sizeBoxH,
|
||||
EasyRichText(
|
||||
'剩余金币数:$_coin',
|
||||
defaultStyle:
|
||||
const TextStyle(color: Colors.white, fontSize: 14),
|
||||
patternList: [
|
||||
//纯数字,前缀里没有数字所以不会误命中
|
||||
EasyRichTextPattern(
|
||||
targetString: _coin,
|
||||
matchOption: 'first',
|
||||
style: const TextStyle(color: AppColors.actionRed),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.sizeBoxH,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_btn(
|
||||
title: paywall?.coinButtonUI ?? '金币解锁',
|
||||
colors: const [Color(0xffFFE8BE), Color(0xffE6B764)],
|
||||
textColor: const Color(0xff694923),
|
||||
onTap: _onCoinUnlock,
|
||||
),
|
||||
18.sizeBoxW,
|
||||
_btn(
|
||||
title: paywall?.cardButtonUI ?? '开通短剧卡免费看',
|
||||
colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)],
|
||||
textColor: Colors.white,
|
||||
onTap: _onCardTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 渐变胶囊按钮(与试看结束遮罩同一套视觉)
|
||||
Widget _btn({
|
||||
required String title,
|
||||
required List<Color> colors,
|
||||
required Color textColor,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: colors),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Text(title, style: TextStyle(color: textColor, fontSize: 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
/// 短剧倍速弹窗:选中档主题色 + 右侧对勾,点击直接 Get.back(倍速)
|
||||
class DramaSpeedSheet extends StatelessWidget {
|
||||
final double speed;
|
||||
|
||||
/// 倍速档位,设计稿按从快到慢排
|
||||
static const speeds = [3.0, 2.0, 1.5, 1.25, 1.0, 0.75];
|
||||
|
||||
/// 默认倍速,列表里带“(默认)”后缀
|
||||
static const normalSpeed = 1.0;
|
||||
|
||||
const DramaSpeedSheet({super.key, this.speed = normalSpeed});
|
||||
|
||||
static Future<double?> show({double speed = normalSpeed}) =>
|
||||
Get.bottomSheet<double>(
|
||||
DramaSpeedSheet(speed: speed),
|
||||
backgroundColor: Colors.transparent, // 圆角由内部 Container 画,别让默认底色露出直角
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
//底部 6 + 末档自带的 12 = 设计稿的 18
|
||||
padding:
|
||||
EdgeInsets.fromLTRB(16, 18, 16, 6 + Get.mediaQuery.padding.bottom),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch, // 分割线要全宽,默认 center 会把它挤成 0
|
||||
children: [
|
||||
//标题行:左侧收起箭头 + 标题在剩余空间居中
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Image.asset('chevron_down.webp'.commonImgPath,
|
||||
width: 24, height: 24),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'倍速',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 16,
|
||||
height: 22 / 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final value in speeds) ...[
|
||||
if (value != speeds.first) 0.5.line,
|
||||
_item(value),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//单个倍速档:46 = 文案行高 22 + 上下各 12 的设计稿间距
|
||||
Widget _item(double value) {
|
||||
final isSelected = speed == value;
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: value),
|
||||
child: SizedBox(
|
||||
height: 46,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
value == normalSpeed ? '${value}x(默认)' : '${value}x',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
height: 22 / 16,
|
||||
color: isSelected
|
||||
? AppColors.actionRed
|
||||
: Colors.white.withValues(alpha: .9),
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Image.asset('check.webp'.commonImgPath, width: 24, height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_model/video_model.dart';
|
||||
import 'package:hgdj/hj_page/drama/drama_detail_page.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_bottom_bar.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_menu.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_paywall_view.dart';
|
||||
import 'package:hgdj/hj_page/drama/view/drama_video_player_logic.dart';
|
||||
import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart';
|
||||
import 'package:hgdj/hj_page/short_video/view/video_progress_widget.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
import 'package:hgdj/tools_base/widget/like_burst_view.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
/// 单集短剧播放器。与短视频播放器分开:短剧不走试看/权益那套,
|
||||
/// 拦不住就挂付费墙,另有双击点赞、连播倒数、本地续播
|
||||
class DramaVideoPlayer extends StatefulWidget {
|
||||
const DramaVideoPlayer({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.videoInfo,
|
||||
this.isCurrentPage,
|
||||
this.isScrolling,
|
||||
this.onCompleted,
|
||||
this.onRemaining,
|
||||
this.progressBottom,
|
||||
this.showEntry = true,
|
||||
this.speed = 1.0,
|
||||
this.startSeconds = 0,
|
||||
this.onCardUnlocked,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final VideoModel? videoInfo;
|
||||
final bool? isCurrentPage;
|
||||
final ValueNotifier<bool>? isScrolling; // 上下滑动切集时隐藏进度条
|
||||
final VoidCallback? onCompleted; // 本条播完
|
||||
final ValueChanged<int>? onRemaining; // 距结束 5 秒内的倒数
|
||||
final double? progressBottom; // 进度条底部留白覆盖值(二级页要让开底部剧集条)
|
||||
final bool showEntry; // 是否挂「查看完整短剧」入口;二级页自己有底部剧集条,传 false
|
||||
final double speed; // 播放倍速,二级页整页共用;Feed 没这个入口,恒为 1.0
|
||||
final int startSeconds; // 一级页带进来的起播秒数,只有进二级页落地的那一集非 0
|
||||
final Future<void> Function()? onCardUnlocked; // 开卡成功后刷新整部剧,让其他集的角标跟着没
|
||||
|
||||
@override
|
||||
State<DramaVideoPlayer> createState() => _DramaVideoPlayerState();
|
||||
}
|
||||
|
||||
// PageView 里 2~3 条同时存活,用 per-实例唯一 tag 隔离各自的播放器 logic
|
||||
class _DramaVideoPlayerState extends State<DramaVideoPlayer>
|
||||
with UniqueTagMixin {
|
||||
//进度条压暗档位:滑动中 / 挂着付费墙都用这一档
|
||||
static const _dimOpacity = 0.4;
|
||||
|
||||
//压暗/复原的淡入淡出时长。滑动只在 ScrollStart/ScrollEnd 各通知一次,
|
||||
//硬跳变正好压在手指刚动和刚松的瞬间,很容易被看成闪烁,给一小段过渡盖住
|
||||
static const _dimDuration = Duration(milliseconds: 150);
|
||||
|
||||
late final DramaVideoPlayerLogic _logic = DramaVideoPlayerLogic(
|
||||
index: widget.index,
|
||||
videoInfo: widget.videoInfo,
|
||||
//挂不挂「查看完整短剧」入口就是刷剧/连播的分界:Feed 挂,二级页自己有剧集条
|
||||
isSerial: !widget.showEntry,
|
||||
isCurrent: widget.isCurrentPage == true,
|
||||
speed: widget.speed,
|
||||
startSeconds: widget.startSeconds,
|
||||
onCardUnlocked: widget.onCardUnlocked,
|
||||
//Feed 里这一集播完 = 进入连播,直接把用户带进二级页;二级页自己传 onCompleted 切下一集
|
||||
onCompleted:
|
||||
widget.onCompleted ?? (widget.showEntry ? _onFeedCompleted : null),
|
||||
onRemaining: widget.onRemaining,
|
||||
);
|
||||
|
||||
/// 双击点赞的爱心动效
|
||||
final _likeBurst = LikeBurstController();
|
||||
|
||||
//信息栏要让开下方这一摞,再留设计稿的 18:
|
||||
//进度条本体 28 + 进度条自己往下让的距离(二级页= 剧集条48 + 底部安全区) + Feed 才有的剧集条 48
|
||||
double get _menuBottom =>
|
||||
28 + (widget.progressBottom ?? 0) + (widget.showEntry ? 48 : 0) + 18;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DramaVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 同 id 换对象时 State 会被复用,logic 必须跟着换
|
||||
_logic.videoInfo = widget.videoInfo;
|
||||
_logic.index = widget.index;
|
||||
_logic.isCurrent = widget.isCurrentPage == true; // 落进度只认当前集,见 _saveResume
|
||||
_logic.setSpeed(widget.speed); // 页面改了倍速,三条活着的都跟上
|
||||
// isCurrentPage 由父级每次 rebuild 传入,是流动属性,只能在这里比出「刚滑回本条」
|
||||
if (widget.isCurrentPage == true && oldWidget.isCurrentPage != true) {
|
||||
_logic.onBecomeCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_likeBurst.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<DramaVideoPlayerLogic>(
|
||||
tag: uniqueTag,
|
||||
init: _logic,
|
||||
builder: (logic) {
|
||||
final ctr = logic.playerCtr;
|
||||
//首帧是否已就绪:下面封面兜底与播放画面是同一判断的两面,取一次免得两处写反
|
||||
final isReady = ctr?.value.isInitialized == true;
|
||||
return VisibilityDetector(
|
||||
key: logic.visibilityKey,
|
||||
onVisibilityChanged: logic.onVisibility,
|
||||
child: GestureDetector(
|
||||
onTap: logic.onTapPlay,
|
||||
//有 onDoubleTap 时单击要等 300ms 才能确认不是双击,这是双击点赞的固有代价
|
||||
onDoubleTapDown: (d) => _likeBurst.markAt(d.localPosition),
|
||||
onDoubleTap: _onDoubleTapLike,
|
||||
onLongPressUp: logic.resetSpeed,
|
||||
onLongPressEnd: (_) => logic.resetSpeed(),
|
||||
onLongPressStart: (_) => logic.speedUp(),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
//首帧出来前(初始化中)和挂着付费墙时(播放器压根不建),底下露的都是纯黑,
|
||||
//铺一层本集封面兜底。条件正好是下面 VideoPlayer 的反面,两者同一帧交接不会重叠;
|
||||
//付费墙自带 80% 深色底,压上去正好是「封面压暗」的效果
|
||||
if (!isReady && logic.video?.cover?.isNotEmpty == true)
|
||||
NetworkImageLoader(
|
||||
imageUrl: logic.video?.cover,
|
||||
borderRadius: 0,
|
||||
//加载中/失败都留黑,默认那个灰底占位 logo 铺满全屏太难看
|
||||
placeHolderWidget: const SizedBox(),
|
||||
),
|
||||
if (isReady)
|
||||
Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: ctr!.value.aspectRatio,
|
||||
child: VideoPlayer(ctr),
|
||||
),
|
||||
),
|
||||
_bufferLoading(logic),
|
||||
FullScreenButton(logic: logic),
|
||||
DramaMenu(
|
||||
videoModel: logic.video,
|
||||
playerCtr: ctr,
|
||||
onSwitchLine: logic.onSwitchLine,
|
||||
showEntry: widget.showEntry,
|
||||
bottomInset: _menuBottom,
|
||||
),
|
||||
//付费墙:只有「金币解锁 / 开短剧卡」两个按钮,文案价格全由接口下发
|
||||
Positioned.fill(
|
||||
child: Visibility(
|
||||
visible: logic.showPaywall,
|
||||
child: DramaPaywallView(
|
||||
video: logic.video, onUnlocked: logic.onUnlocked),
|
||||
),
|
||||
),
|
||||
LikeBurstView(controller: _likeBurst),
|
||||
// 进度条 + Feed 里的「查看完整短剧」入口(二级页的底部条由页面自己画)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_progressBar(logic),
|
||||
if (widget.showEntry)
|
||||
DramaBottomBar(
|
||||
leadingIcon: 'full_drama.webp',
|
||||
text:
|
||||
'查看完整短剧·全${logic.video?.dramaInfo?.totalEpisode ?? 0}集',
|
||||
tailIcon: 'chevron_right.webp',
|
||||
onTap: _openDrama,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Feed 里这一集播完:只有还停在本条时才跳,别把已经滑走的那条带进来
|
||||
void _onFeedCompleted() {
|
||||
if (widget.isCurrentPage != true || !mounted) return;
|
||||
_openDrama(startNext: true);
|
||||
}
|
||||
|
||||
/// 进短剧二级页。[startNext] true = 本集已播完,从下一集接着播
|
||||
void _openDrama({bool startNext = false}) {
|
||||
_logic.playerCtr?.pause();
|
||||
//Feed 里放的是第几集就从第几集接上,下标越界由二级页钳住
|
||||
final index = (widget.videoInfo?.episodeNo ?? 1) - 1 + (startNext ? 1 : 0);
|
||||
//这一刻的秒数直接透给连播模式,不写进续播记录——刷剧模式只留「看过」不动进度(DramaMenu._openDrama 同此)。
|
||||
//本集已播完时从下一集的 0 秒起
|
||||
Get.to(
|
||||
() => DramaDetailPage(
|
||||
drama: widget.videoInfo?.dramaInfo,
|
||||
initialIndex: index < 0 ? 0 : index,
|
||||
initialSeconds:
|
||||
startNext ? 0 : (_logic.playerCtr?.value.position.inSeconds ?? 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 双击点赞:先弹爱心给反馈,再去调接口(已点赞的只弹爱心不重复请求)
|
||||
void _onDoubleTapLike() {
|
||||
_likeBurst.burst();
|
||||
_logic.likeOnce();
|
||||
}
|
||||
|
||||
/// 内嵌进度条。压暗态只有一档 40%,用**一个** AnimatedOpacity 表达:上下滑动切集时
|
||||
/// (不做全透明,留个淡影不至于整条凭空消失)、挂着付费墙时都压到 40%,带 150ms 淡入淡出。
|
||||
/// 嵌套两层 Opacity 会相乘,别拆开写
|
||||
Widget _progressBar(DramaVideoPlayerLogic logic) {
|
||||
final (minSeek, maxSeek) = logic.previewRange;
|
||||
final Widget bar = VideoProgressWidget(
|
||||
controller: logic.playerCtr,
|
||||
padding: EdgeInsets.only(
|
||||
left: 12, right: 12, bottom: widget.progressBottom ?? 0),
|
||||
//试看放的是完整视频,可拖区间夹在 [previewStart, previewStart+previewSeconds];挂墙后整条禁拖。
|
||||
//与长视频页同一套(VideoMenuView 的 minSeek/maxSeek + enableSeek)
|
||||
minSeekDuration: minSeek,
|
||||
maxSeekDuration: maxSeek,
|
||||
enableSeek: !logic.showPaywall,
|
||||
);
|
||||
final scrolling = widget.isScrolling;
|
||||
if (scrolling == null)
|
||||
return Opacity(opacity: logic.showPaywall ? _dimOpacity : 1, child: bar);
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: scrolling,
|
||||
//只做透明+屏蔽点击,不能用 Visibility——它会卸载 child,重建时 initState 会拿旧 controller 挂监听。
|
||||
//bar 走 child 传进来,滑动时只重建 IgnorePointer/AnimatedOpacity 这两层,进度条本身不重建
|
||||
builder: (_, isScroll, child) => IgnorePointer(
|
||||
ignoring: isScroll,
|
||||
child: AnimatedOpacity(
|
||||
opacity: isScroll || logic.showPaywall ? _dimOpacity : 1,
|
||||
duration: _dimDuration,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
child: bar,
|
||||
);
|
||||
}
|
||||
|
||||
/// 未就绪/缓冲中的居中转圈。挂着付费墙时不转——那不是在加载,是没权限
|
||||
Widget _bufferLoading(DramaVideoPlayerLogic logic) {
|
||||
if (logic.showPaywall) return const SizedBox();
|
||||
const indicator = Center(
|
||||
child:
|
||||
CupertinoActivityIndicator(color: AppColors.actionRed, radius: 15));
|
||||
final ctr = logic.playerCtr;
|
||||
if (ctr == null) return indicator; // 控制器还没建好,同样转圈
|
||||
return ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: ctr,
|
||||
builder: (_, value, __) => !value.isInitialized || value.isBuffering
|
||||
? indicator
|
||||
: const SizedBox(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/widgets.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/short_video/view/video_player_base_logic.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
|
||||
import 'package:hgdj/tools_base/global_store/store.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
/// 单集短剧的播放逻辑,PageView 每项一个实例。
|
||||
/// 能不能播只认服务端 canPlay,拦不住就挂付费墙;续播位置纯本地记
|
||||
class DramaVideoPlayerLogic extends VideoPlayerBaseLogic
|
||||
with WidgetsBindingObserver {
|
||||
/// 开卡放行广播:PageView 预建的上下两条已各自问过 canPlay,不叫醒就还挂着买卡前那道墙
|
||||
static final ValueNotifier<int> unlockRevision = ValueNotifier(0);
|
||||
|
||||
static void broadcastUnlock() => unlockRevision.value++;
|
||||
|
||||
DramaVideoPlayerLogic({
|
||||
required this.index,
|
||||
required this.videoInfo,
|
||||
required this.isSerial,
|
||||
required this.isCurrent,
|
||||
this.speed = 1.0,
|
||||
this.startSeconds = 0,
|
||||
this.onCompleted,
|
||||
this.onRemaining,
|
||||
this.onCardUnlocked,
|
||||
});
|
||||
|
||||
/// 由页面同步刷新:PageView 复用 State 时不跟着换就会一直读旧 model
|
||||
int index;
|
||||
VideoModel? videoInfo;
|
||||
|
||||
/// 是不是连播模式(二级页)。续播记录只在连播模式读写——
|
||||
/// 刷剧模式每划过一部剧就写一条,会把真正在追的剧挤出淘汰线
|
||||
final bool isSerial;
|
||||
|
||||
/// 是不是 PageView 停在的那一集,由页面在 didUpdateWidget 里同步。
|
||||
/// 落进度只认它:二级页同时活着三条,邻居那两条停在 0 秒会把在看的盖掉。
|
||||
/// 不能换成 isVisible——进全屏/被弹窗盖住都算不可见,但在播的仍是这一集
|
||||
bool isCurrent;
|
||||
|
||||
/// 播放倍速,由二级页整页统一给。控制器每集新建,起播时要补上
|
||||
double speed;
|
||||
|
||||
/// 一级页带进来的起播秒数(刷剧模式不写库,位置只能靠参数递)。用完清零,滑走再回来从头看
|
||||
int startSeconds;
|
||||
|
||||
/// 本条播完的回调,每次播放只回调一次
|
||||
final VoidCallback? onCompleted;
|
||||
|
||||
/// 距本条结束还剩几秒(只在最后 5 秒内回调,二级页据此提示「Ns后播放下一集」)
|
||||
final ValueChanged<int>? onRemaining;
|
||||
int _lastRemaining = -1;
|
||||
|
||||
/// 开卡成功后由页面刷新整部剧:开卡是整部放行,其他集的角标也得跟着没
|
||||
final Future<void> Function()? onCardUnlocked;
|
||||
|
||||
bool showPaywall = false; // canPlay=false 时挂付费墙
|
||||
bool _loading = false; // _loadEpisode 进行中,挡住重入
|
||||
bool _reloadPending = false; // 加载中又来了放行信号,等这次跑完补一次,别丢
|
||||
|
||||
/// 正在放试看片。地址、到点收尾、滑回重看都跟正片不一样
|
||||
bool _isPreview = false;
|
||||
bool _liking = false; // 点赞请求中,防连点
|
||||
DateTime? _savedAt; // 上次落进度的时刻,节流用
|
||||
|
||||
/// 起播位置是否已定好。initPlayer 里 addListener 排在续播 seek 之前,
|
||||
/// 初始化期间的任何 value 变化都会触发 onTick(0),不挡就把要续的位置抹了
|
||||
bool _resumeDone = false;
|
||||
|
||||
@override
|
||||
VideoModel? get video => videoInfo;
|
||||
|
||||
@override
|
||||
bool get isBlocked => showPaywall;
|
||||
|
||||
@override
|
||||
String get debugTag => 'drama $index';
|
||||
|
||||
String? get _mediaId => videoInfo?.dramaInfo?.id;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
//只有连播模式要盯挂起:从后台被杀不会再走 onClose,这一集看到哪就丢了
|
||||
if (isSerial) WidgetsBinding.instance.addObserver(this);
|
||||
unlockRevision.addListener(_onUnlockBroadcast);
|
||||
globalStore.dramaCardGained.addListener(_onUnlockBroadcast);
|
||||
_loadEpisode(); // 地址是现签的、canPlay 只认服务端,拿到详情再决定起播还是挂墙
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
if (isSerial) WidgetsBinding.instance.removeObserver(this);
|
||||
unlockRevision.removeListener(_onUnlockBroadcast);
|
||||
globalStore.dramaCardGained.removeListener(_onUnlockBroadcast);
|
||||
_saveResume();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
/// app 挂起/切后台:补一次进度
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_saveResume();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onPauseAll() {
|
||||
_saveResume();
|
||||
super.onPauseAll();
|
||||
}
|
||||
|
||||
/// 竖滑回到本条:进度归零重看
|
||||
void onBecomeCurrent() {
|
||||
resetCompleted();
|
||||
_lastRemaining = -1;
|
||||
_savedAt = null; // 从头重看,下一个 tick 就落 0,别被上一轮节流挡住
|
||||
//只撤试看到点那道墙;没配试看的墙撤了会去播一个空地址
|
||||
if (_isPreview && showPaywall) {
|
||||
showPaywall = false;
|
||||
notify();
|
||||
}
|
||||
playerCtr?.seekTo(
|
||||
Duration(seconds: _isPreview ? _previewStart : 0)); //试看重看要回到起播点,不是 0
|
||||
syncPlay();
|
||||
_markWatched(); // 滑到这条才算看过:onInit 预建时 isCurrent 还是 false
|
||||
}
|
||||
|
||||
/// 刷剧模式的观看历史:只刷观看时间(列表按它倒序),进度沿用旧记录不覆盖二级页的续播位置。
|
||||
/// 挂着墙 = 一秒没看成,不记;预建的邻居也不记
|
||||
void _markWatched() {
|
||||
if (isSerial || showPaywall || !isCurrent) return;
|
||||
dramaResume.markWatched(
|
||||
mediaId: _mediaId,
|
||||
contentId: videoInfo?.dramaEpisode?.id,
|
||||
drama: videoInfo?.dramaInfo, // 历史记录页要拿它画卡片
|
||||
);
|
||||
}
|
||||
|
||||
/// 切回短剧 tab / 被别的路由让开后又回到本条:还挂着墙就重拉一次钱包。
|
||||
/// 墙上的余额是进墙那一刻的快照,去「我的」充完金币回来没人通知它,
|
||||
/// 不对一次的话充过钱的人还是会被当成余额不足
|
||||
@override
|
||||
void onVisibility(VisibilityInfo info) {
|
||||
final wasVisible = isVisible;
|
||||
super.onVisibility(info);
|
||||
if (!wasVisible && isVisible && showPaywall) globalStore.refreshWallet();
|
||||
}
|
||||
|
||||
/// 别处开卡放行 / 刷用户信息刷出了短剧权益:这一集只要还没放行就重问一次服务端。
|
||||
/// 不能只看 showPaywall——试看还在放的时候墙没挂起来,信号会被丢掉,
|
||||
/// 等试看放完照样挂一道买卡前的墙(后台加卡、在「我的」下拉刷新后进短剧就是这个时序)
|
||||
void _onUnlockBroadcast() {
|
||||
if (isClosed || videoInfo?.dramaEpisode?.canPlay == true) return;
|
||||
_loadEpisode();
|
||||
}
|
||||
|
||||
int get _previewStart => videoInfo?.dramaEpisode?.previewStart ?? 0;
|
||||
|
||||
//试看看到第几秒为止:起播点 + 时长,previewSeconds 是时长不是绝对位置
|
||||
int get _previewEnd =>
|
||||
_previewStart + (videoInfo?.dramaEpisode?.previewSeconds ?? 0);
|
||||
|
||||
/// 试看可拖区间 [起, 止],不在试看态时 (null, null)。同长视频页 VideoMenuView._calcSeekRange
|
||||
(Duration?, Duration?) get previewRange => _isPreview
|
||||
? (Duration(seconds: _previewStart), Duration(seconds: _previewEnd))
|
||||
: (null, null);
|
||||
|
||||
/// 没解锁 + 开关开 + 有地址 + 有时长,刷剧和连播两个页面一视同仁。
|
||||
/// 秒数必须 >0:试看地址给的是完整视频、后端不裁片,配成 0 就是整集免费送
|
||||
bool _canPreview(MediaContent? episode) =>
|
||||
episode?.canPlay != true &&
|
||||
episode?.previewEnabled == true &&
|
||||
(episode?.previewSeconds ?? 0) > 0 &&
|
||||
episode?.previewVideoUrl?.isNotEmpty == true;
|
||||
|
||||
/// 拉分集详情并决定起播/挂墙。重入会开出两个播放器控制器:两次都拿同一个旧的去 dispose
|
||||
/// (双重释放),后建的那个还会把先建的顶掉、没人释放。买卡那一下 onUnlocked 和
|
||||
/// 「刷出短剧权益」的广播是前后脚到的,不挡就会撞上
|
||||
Future<void> _loadEpisode() async {
|
||||
//加载中又来一个信号:记下来跑完再补一次,不能直接丢——在飞的这次是信号到达前发出的,
|
||||
//拿回来的可能还是放行前那份
|
||||
if (_loading) {
|
||||
_reloadPending = true;
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
try {
|
||||
await _loadEpisodeInner();
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
if (_reloadPending && !isClosed) {
|
||||
_reloadPending = false;
|
||||
await _loadEpisode();
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉分集详情:能播就起播并接上续播位置;不能播的,配了试看就先放试看片,其余挂付费墙
|
||||
Future<void> _loadEpisodeInner() async {
|
||||
final target = videoInfo;
|
||||
final prev = target?.dramaEpisode;
|
||||
final episode = await DramaService.fetchEpisode(prev?.id);
|
||||
if (isClosed) return;
|
||||
if (episode == null) {
|
||||
showToast("剧集加载失败");
|
||||
//按最后已知的 canPlay 归位,不能顺手撤墙——撤了只剩空转圈,连重试入口都没有
|
||||
showPaywall = prev?.canPlay != true;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
//兜底沿用 Feed 那份;两个地址判空串不判 null——服务端没有时给的是 ""
|
||||
episode.previewEnabled ??= prev?.previewEnabled;
|
||||
episode.previewStart ??= prev?.previewStart;
|
||||
episode.previewSeconds ??= prev?.previewSeconds;
|
||||
if (episode.previewVideoUrl?.isNotEmpty != true)
|
||||
episode.previewVideoUrl = prev?.previewVideoUrl;
|
||||
if (episode.previewH265Url?.isNotEmpty != true)
|
||||
episode.previewH265Url = prev?.previewH265Url;
|
||||
final usePreview = _canPreview(episode);
|
||||
target
|
||||
?..dramaEpisode = episode
|
||||
//试看只换地址,265 选流/解码回退/切线路那一整套照旧走
|
||||
..sourceURL = usePreview ? episode.previewVideoUrl : episode.videoUrl
|
||||
..h265Url = usePreview ? episode.previewH265Url : episode.h265Url
|
||||
..playTime = episode.playTime
|
||||
..freeArea = episode.isFree == true;
|
||||
// 回写选集列表里的那一条,解锁后再开选集面板不会还挂着锁
|
||||
final list = target?.dramaInfo?.episodeList;
|
||||
final index = list?.indexWhere((e) => e.id == episode.id) ?? -1;
|
||||
if (index >= 0) list![index] = episode;
|
||||
if (episode.canPlay != true && !usePreview) {
|
||||
showPaywall = true;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
_isPreview = usePreview;
|
||||
//墙留着当遮罩盖住建播放器这段,撤早了只剩封面压一个大转圈。
|
||||
//必须传 target 不能传 video:新地址写回的是 target,videoInfo 可能已被 didUpdateWidget 换成另一个对象
|
||||
await initPlayer(target);
|
||||
_applySpeed(); // 新控制器默认 1 倍,把页面选的档位补上
|
||||
//续播只在连播模式定位:刷剧模式不写库,正片一律从 0 播。一级页带进来的位置优先——
|
||||
//从刷剧页点进来要接着那边的秒数往下放,试看片同样接着放。
|
||||
//试看不认本地续播记录(记的是正片进度,一跳就越过试看区间),没带位置就从后端给的 previewStart 起
|
||||
final last = isSerial && !usePreview ? dramaResume.of(_mediaId) : null;
|
||||
final resume = startSeconds > 0
|
||||
? startSeconds
|
||||
: (last?.contentId == episode.id ? last!.progressSeconds : 0);
|
||||
final seconds = usePreview && resume <= 0 ? _previewStart : resume;
|
||||
startSeconds = 0;
|
||||
if (seconds > 0 && !isClosed)
|
||||
await playerCtr?.seekTo(Duration(seconds: seconds));
|
||||
if (isSerial) _resumeDone = !usePreview; // 试看那段不落进度,正片才从这一刻起作数
|
||||
if (isClosed) return;
|
||||
//位置定好才撤墙,中间不露转圈。播放器建失败也照撤——
|
||||
//已经付过钱的人不该再看见付费墙,对着转圈重试也比诱导二次付费强
|
||||
showPaywall = false;
|
||||
notify();
|
||||
syncPlay(); // initPlayer 里那次被 isBlocked 挡掉了,撤墙后补一次
|
||||
if (!isSerial) _markWatched();
|
||||
}
|
||||
|
||||
/// 页面改了倍速:已就绪的立刻跟上,还没建好的等 _loadEpisode 起播时补
|
||||
void setSpeed(double value) {
|
||||
if (speed == value) return;
|
||||
speed = value;
|
||||
_applySpeed();
|
||||
}
|
||||
|
||||
void _applySpeed() {
|
||||
if (playerCtr?.value.isInitialized == true)
|
||||
playerCtr?.setPlaybackSpeed(speed);
|
||||
}
|
||||
|
||||
/// 长按 2 倍抬手后回到用户选的档位,不是基类写死的 1.0
|
||||
@override
|
||||
void resetSpeed() => _applySpeed();
|
||||
|
||||
/// 解锁成功(金币 / 开短剧卡):重拉详情,服务端放行就接着播。
|
||||
/// 全程静默,墙由 _loadEpisode 在播放器就绪那一刻撤——提前撤会先空转一圈
|
||||
Future<void> onUnlocked({bool byCard = false}) async {
|
||||
await _loadEpisode();
|
||||
//开卡是第三方支付,弹窗关了不等于到账:墙还挂着就是没放行,别去清别的集的角标
|
||||
if (isClosed || !byCard || showPaywall) return;
|
||||
//广播挂在这儿而不是页面回调里:刷剧页没传 onCardUnlocked,挂那儿的话它的邻居永远收不到
|
||||
broadcastUnlock();
|
||||
await onCardUnlocked?.call();
|
||||
}
|
||||
|
||||
/// 双击点赞,已点赞不重复调接口。点赞挂在「剧」上,不是当前这一集
|
||||
Future<void> likeOnce() async {
|
||||
final model = video;
|
||||
if (model == null || _liking) return;
|
||||
if (model.vidStatus?.hasLiked == true) return;
|
||||
_liking = true;
|
||||
final ok = await CommonService.sendLike(_mediaId, 'drama');
|
||||
_liking = false;
|
||||
if (!ok || isClosed) return;
|
||||
model.vidStatus?.hasLiked = true;
|
||||
model.likeCount = (model.likeCount ?? 0) + 1;
|
||||
notify();
|
||||
}
|
||||
|
||||
/// 每 500ms 一次:落一次进度(节流 10 秒) + 距结束 5 秒内让底部条倒数。
|
||||
/// 连播自动切集,中间没有退出/暂停这类一次性时机,只能靠 tick 落
|
||||
@override
|
||||
void onTick(int position) {
|
||||
//试看到点就收:试看不落续播、也不提示「Ns后播下一集」(进得了试看态就一定有正数秒数)
|
||||
if (_isPreview) {
|
||||
if (position >= _previewEnd) _endPreview();
|
||||
return;
|
||||
}
|
||||
_saveResume(seconds: position, throttle: true);
|
||||
final duration = playerCtr?.value.duration.inSeconds ?? 0;
|
||||
if (duration <= 0) return;
|
||||
final remaining = duration - position;
|
||||
if (remaining <= 0 || remaining > 5) {
|
||||
_lastRemaining = -1;
|
||||
return;
|
||||
}
|
||||
if (remaining == _lastRemaining) return;
|
||||
_lastRemaining = remaining;
|
||||
onRemaining?.call(remaining);
|
||||
}
|
||||
|
||||
/// 本集播完:记成从头,切到下一集后由下一集的第一次 onTick 覆盖掉
|
||||
@override
|
||||
void onFinished() {
|
||||
//试看片放完 = 试看到点,当成整集看完会把用户直接带进二级页
|
||||
if (_isPreview) {
|
||||
_endPreview();
|
||||
return;
|
||||
}
|
||||
_saveResume(seconds: 0);
|
||||
onCompleted?.call();
|
||||
}
|
||||
|
||||
/// 试看结束:停在最后一帧再盖墙。墙一挂 isBlocked 就为真,syncPlay 不会把它重新播起来
|
||||
void _endPreview() {
|
||||
if (showPaywall) return;
|
||||
playerCtr?.pause();
|
||||
showPaywall = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
/// 点屏播放/暂停,暂停时落一次进度。
|
||||
/// 挂着墙一律不动播放器:试看到点后墙背后留着个暂停好的控制器,点一下就能接着放完
|
||||
void onTapPlay() {
|
||||
if (showPaywall) return;
|
||||
if (togglePlay()) _saveResume();
|
||||
}
|
||||
|
||||
/// 记一次续播位置(纯本地)。三道闸见 [isSerial] / [isCurrent] / [_resumeDone] 的字段注释。
|
||||
/// [throttle] 只给播放中那条用;节流字段是实例级的,换集必然先落一条。
|
||||
/// [seconds] 不传取播放器当前位置——播放器起不来时位置恒为 0,写下去会抹平上次的记录,这种一条不记
|
||||
void _saveResume({int? seconds, bool throttle = false}) {
|
||||
if (!isSerial || !isCurrent || !_resumeDone) return;
|
||||
final ctr = playerCtr;
|
||||
if (seconds == null && ctr?.value.isInitialized != true) return;
|
||||
final now = DateTime.now();
|
||||
if (throttle &&
|
||||
_savedAt != null &&
|
||||
now.difference(_savedAt!).inSeconds < 10) return;
|
||||
_savedAt = now;
|
||||
dramaResume.record(
|
||||
mediaId: _mediaId,
|
||||
contentId: videoInfo?.dramaEpisode?.id,
|
||||
seconds: seconds ?? ctr!.value.position.inSeconds,
|
||||
drama: videoInfo?.dramaInfo, // 历史记录页要拿它画卡片
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user