初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+294
View File
@@ -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();
}
}
+194
View File
@@ -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 都是剧 idkey 必须用子集 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,
),
),
);
}
}
+102
View File
@@ -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);
}
}
+82
View File
@@ -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,
);
},
),
),
),
);
}
}
+141
View File
@@ -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
//返回 FutureCErrorWidget 的重试按钮靠返回值出 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);
}
}
+249
View File
@@ -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),
],
],
),
),
],
),
),
);
}
}
+214
View File
@@ -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:新地址写回的是 targetvideoInfo 可能已被 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, // 历史记录页要拿它画卡片
);
}
}