初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -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, // 历史记录页要拿它画卡片
);
}
}