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