初始化

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,131 @@
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../pre_sale/pre_sale_provider.dart';
/// 长视频状态类型:逻辑判断一律用 [type][LongVideoStatus.desc] 仅用于展示
enum LongVideoStatusType {
none, // 无状态(自己的视频 / 免费次数内重复观看)
freeVideo, // 免费视频
freeRemain, // 免费视频剩余N次
vipFree, // 已享VIP免费特权
purchased, // 已购买完整版
coinFreeRemain, // 免费金币观影数剩余N次
coinFree, // 已享金币视频免费特权
skipPreview, // 跳过预览(含「N金币 跳过预览」)
}
class LongVideoStatus {
final LongVideoStatusType type;
final String desc; // 展示文案
final bool isNeedVip; // 需开会员才能看完整片
final bool isNeedBuy; // 需花金币购买才能看完整片
const LongVideoStatus({
this.type = LongVideoStatusType.none,
this.desc = '',
this.isNeedVip = false,
this.isNeedBuy = false,
});
/// 看完整片需要付费(开会员或买金币)。调用方别写 `isNeedVip || isNeedBuy`
/// videoStatus 是个每次都重算的 getter,写两次就多算一遍
bool get isNeedPay => isNeedVip || isNeedBuy;
}
//复用多处的固定状态,抽出来免得同一串文案散落
const _none = LongVideoStatus();
const _coinFree =
LongVideoStatus(type: LongVideoStatusType.coinFree, desc: '已享金币视频免费特权');
const _skipVip = LongVideoStatus(
type: LongVideoStatusType.skipPreview, desc: '跳过预览', isNeedVip: true);
const _skipBuy = LongVideoStatus(
type: LongVideoStatusType.skipPreview, desc: '跳过预览', isNeedBuy: true);
/// 计算长视频的观看状态(展示文案 + 是否需付费/开会员)
///
/// ⚠️ 分支顺序即业务优先级,命中即返回,不要随意调整前后
/// ⚠️ 有副作用:内部 [FreePlayManager.useFreePlay] 会扣免费次数并上报,
/// 调用方一次 build 只调一次、结果存局部变量,别在同一段代码里反复读
///
/// 会员权益背景:
/// - 普通会员:VIP长视频、VIP抖音、社区VIP帖子、图集VIP、小说VIP、ACG VIP
/// - 高级会员:VIP + 金币视频(长视频/抖音)、社区VIP帖子、图集金币、小说金币、ACG;暗网视频和社区金币帖子除外
/// - 超级会员:全网通,含暗网 + 社区金币帖子
/// - 黄游单独购买
LongVideoStatus longVideoStatus(VideoModel? model) {
if (model == null) return _none;
if (globalStore.isMe(model.publisher?.uid)) return _none; // 自己发布的视频
// 必须先读:下面 canFree 里的 useFreePlay 会扣次数,读晚了就少 1
// (播放页进来时 _initPlayer 已扣过一次,所以这里读到的通常是「不含当前这次」的剩余数,
// 下面 +1 补回当前这次,和 GuideFreeTrialSheet.remainCount 同口径)
final freeCount = FreePlayManager().remain?.watchCount ?? 0;
final isVIP = globalStore.isVIP;
final coins = model.originCoins;
final isZeroCoin = coins == 0; // null 是后端没下发价格,不能当 0 处理
final hasCoin = (coins ?? 0) > 0;
final hasPaid = model.vidStatus?.hasPaid == true;
final coinFreeLeft = coinFreeCount(model);
// late final = 惰性:条件短路时不触发;多处读取也只调一次(useFreePlay 有扣次数/发请求的副作用)
late final canFree = FreePlayManager().useFreePlay(model);
if (model.freeArea == true) {
return const LongVideoStatus(
type: LongVideoStatusType.freeVideo, desc: '免费视频');
}
if (!isVIP && freeCount >= 0 && isZeroCoin && canFree) {
return LongVideoStatus(
type: LongVideoStatusType.freeRemain, desc: '免费视频剩余${freeCount + 1}');
}
if (isVIP && isZeroCoin) {
// 交给 VipFreeTipView 显示,独立于操作台、3 秒后消失
return const LongVideoStatus(
type: LongVideoStatusType.vipFree, desc: '已享VIP免费特权');
}
if (hasPaid) {
return const LongVideoStatus(
type: LongVideoStatusType.purchased, desc: '已购买完整版');
}
if (!isVIP && isZeroCoin && canFree) {
return _none; // 免费次数内看过的视频,重复观看仍免费(不显示文案)
}
if (coinFreeLeft >= 0) {
return LongVideoStatus(
type: LongVideoStatusType.coinFreeRemain,
desc: '免费金币观影数剩余: $coinFreeLeft次');
}
// 往下不再判 !hasPaid:已购的上面就 return 了,走到这儿必定是未购
if (hasCoin && globalStore.isAWVIP) {
// 暗网视频要顶级会员,否则仍需单独买
if (model.isDarkTag && !globalStore.isVIPTopLevel) {
return LongVideoStatus(
type: LongVideoStatusType.skipPreview,
desc: '${model.coins}金币 跳过预览',
isNeedBuy: true);
}
return _coinFree;
}
// 二级会员 / 金币免费期内 / 本片金币已抵扣,都算已享金币免费特权
if ((hasCoin && globalStore.isSuperVip) ||
(isVIP &&
DateTimeUtil.calTime3(globalStore.meInfo?.goldVideoFreeExpire) > 0 &&
(coins ?? 50) <= 50) ||
(isVIP && hasCoin && model.coins == 0)) {
return _coinFree;
}
if (!isVIP && isZeroCoin && !canFree) return _skipVip; // 免费次数已用完的 VIP 视频
if (hasCoin) return _skipBuy; // 金币视频,单独买
return _skipVip; // 价格没下发,兜底按开会员引导
}
/// 金币视频的权益免费观看次数;-1 表示不享受该权益
int coinFreeCount(VideoModel? model) {
if (model == null || !model.isCoinVideo()) return -1; // 非金币视频
if (model.freeArea == true) return -1;
if (globalStore.isVIP && model.coins == 0) return -1; // vip金币视频免看
if (model.videoType == 1) return -1; // 动漫视频不享受金币免次数权益
return presaleProvider.coinVideoFreeCount;
}
+140
View File
@@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import '../../../hj_model/splash/ads_model.dart';
import '../../../tools_base/banner/ads_banner_widget.dart';
import '../../../tools_base/global_store/store.dart';
/// 播放前贴片广告(Banner + 倒计时关闭)。
/// 倒计时完或 VIP 关闭走 [onFinish],未到时间的非 VIP 走 [onToVip] 引导开通
class VideoAdWidget extends StatefulWidget {
final List<AdsInfoModel>? adsInfos; // 轮播广告数据
final bool showBackArrow; // 是否显示左上角返回箭头
final VoidCallback? onFinish; // 广告结束/关闭回调
final VoidCallback? onToVip; // 未到时间点关闭 → 引导开通 VIP
const VideoAdWidget({
super.key,
this.adsInfos,
this.showBackArrow = true,
this.onFinish,
this.onToVip,
});
@override
State<VideoAdWidget> createState() => _VideoAdWidgetState();
}
class _VideoAdWidgetState extends State<VideoAdWidget> {
// 倒计时用 ValueNotifier 局部刷新:每秒只重建关闭按钮那行字,
// 不再整棵树 setState(否则 Swiper/曝光检测/图片每秒白重建一次)
final _countdown = ValueNotifier(0); // 剩余强制观看秒数,<=0 表示可关闭
Timer? _timer;
// 是否可关闭:VIP / 倒计时结束
bool get _canClose => globalStore.isVIP || _countdown.value <= 0;
@override
void initState() {
super.initState();
_countdown.value = widget.adsInfos?.firstOrNull?.watchTime ?? 0;
_timer = Timer.periodic(const Duration(seconds: 1), _onTick);
}
@override
void dispose() {
_timer?.cancel();
_countdown.dispose();
super.dispose();
}
// 每秒递减,归零后停表
void _onTick(Timer _) {
_countdown.value--;
if (_countdown.value <= 0) {
_timer?.cancel();
_timer = null;
}
}
// 关闭按钮文案
String _closeLabel(int sec) {
if (sec <= 0) return '关闭';
return globalStore.isVIP ? '${sec}s | 关闭广告' : '${sec}s | VIP可关闭广告';
}
// 点击关闭:可关闭则结束广告,否则引导开通 VIP
void _onCloseTap() {
if (!_canClose) {
widget.onToVip?.call();
return;
}
_timer?.cancel();
widget.onFinish?.call();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
// 广告轮播 Banner
AdsBannerWidget(
widget.adsInfos,
width: 329,
height: 88,
autoPlayMs: 2000,
isIndicatorBottomCenter: true,
),
_closeBtn(),
if (widget.showBackArrow) _backBtn(),
],
),
);
}
// 右上角倒计时/关闭按钮(整棵树里只有这行字随倒计时刷新)
Widget _closeBtn() {
return Positioned(
top: 10,
right: 16,
child: GestureDetector(
onTap: _onCloseTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
),
child: ValueListenableBuilder<int>(
valueListenable: _countdown,
builder: (_, sec, __) => Text(
_closeLabel(sec),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
);
}
// 左上角返回
Widget _backBtn() {
return Positioned(
top: 0,
left: 0,
child: GestureDetector(
onTap: () => Get.back(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Image.asset("back_circle.png".commonImgPath, width: 24),
),
),
);
}
}
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:like_button/like_button.dart';
/// 底部操作栏:观看量 + 点赞/收藏/分享。计数与状态就地改在传入的 [VideoModel] 上,
/// 按 mediaInfo 是否为空区分漫画(video)/长视频(SP) 走不同接口。分享交外部处理
class VideoDetailBottomMenu extends StatefulWidget {
final VideoModel? model;
final VoidCallback? onShare;
const VideoDetailBottomMenu({super.key, this.model, this.onShare});
@override
State<VideoDetailBottomMenu> createState() => _VideoDetailBottomMenuState();
}
class _VideoDetailBottomMenuState extends State<VideoDetailBottomMenu> {
// ========== 数据 ==========
VideoModel? get videoModel => widget.model;
bool get isLike => videoModel?.vidStatus?.hasLiked ?? false;
bool get isCollect => videoModel?.vidStatus?.hasCollected ?? false;
bool get isCartoon => videoModel?.mediaInfo != null; // 有 mediaInfo 即漫画,否则长视频
// ========== 请求防重入 ==========
bool _isLiking = false;
bool _isCollecting = false;
// 统一的灰色文案样式(观看量/点赞/收藏/分享)
static const _labelStyle = TextStyle(color: Color(0xff989898), fontSize: 12);
/// 计数±1:原值为 null 时按操作前的状态兜底,保证取消后不会变成负数
int _nextCount(int? cur, bool wasOn) =>
(cur ?? (wasOn ? 1 : 0)) + (wasOn ? -1 : 1);
// ========== 点赞 ==========
Future<bool> _onLike() async {
if (_isLiking) return isLike;
_isLiking = true;
try {
final preLike = isLike; // 请求前的状态,后续增减都以它为准
final bizType = isCartoon ? "video" : "SP";
if (preLike) {
await CommonService.cancelLike(videoModel?.id, bizType);
} else {
await CommonService.sendLike(videoModel?.id, bizType);
}
videoModel?.vidStatus?.hasLiked = !preLike;
videoModel?.likeCount = _nextCount(videoModel?.likeCount, preLike);
} catch (e) {
debugLog(e);
}
_isLiking = false;
if (mounted) setState(() {});
return videoModel?.vidStatus?.hasLiked ?? false;
}
// ========== 收藏 ==========
void _onCollect() async {
if (_isCollecting) return;
_isCollecting = true;
try {
final preCollect = isCollect;
if (isCartoon) {
preCollect
? await ACGService.deleteBookshelf(videoModel?.id ?? "")
: await ACGService.addBookshelf(videoModel?.id ?? "");
} else {
await MineService.postCollect(videoModel?.id, "SP", !preCollect);
}
videoModel?.collectCount =
_nextCount(videoModel?.collectCount, preCollect);
videoModel?.vidStatus?.hasCollected = !preCollect;
videoModel?.mediaInfo?.mediaStatus?.hasCollected = !preCollect;
videoModel?.mediaInfo?.countCollect = videoModel?.collectCount;
showToast(!preCollect ? "收藏成功" : "取消收藏成功");
} catch (e) {
debugLog(e);
}
_isCollecting = false;
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text("${videoModel?.playCount?.countStr ?? ""}观看量",
style: _labelStyle),
),
_likeItem(),
12.sizeBoxW,
_collectItem(),
12.sizeBoxW,
_shareItem(),
],
);
}
Widget _likeItem() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
LikeButton(
isLiked: isLike,
size: 24,
likeBuilder: (isLiked) => Image.asset(
isLiked
? "like_red.png".commonImgPath
: "video_like_grey.webp".videoPath,
),
onTap: (_) => _onLike(),
),
Text(videoModel?.likeCount?.countOr("点赞") ?? "点赞", style: _labelStyle),
],
);
}
Widget _collectItem() {
return GestureDetector(
onTap: _onCollect,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 收藏状态切换时,图标 grey↔red 做 scale 弹出过渡
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Image.asset(
isCollect
? 'collect_red.png'.commonImgPath
: 'collect_grey.png'.commonImgPath,
key: ValueKey(
isCollect), // key 按状态区分,AnimatedSwitcher 才当成新 child 触发动画
width: 24,
),
),
2.sizeBoxW,
Text(videoModel?.collectCount.countOr('收藏') ?? '收藏',
style: _labelStyle),
],
),
);
}
Widget _shareItem() {
return InkWell(
enableFeedback: false,
onTap: () => widget.onShare?.call(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset("share_grey.png".commonImgPath, width: 24, height: 24),
2.sizeBoxW,
Text("分享", style: _labelStyle),
],
),
);
}
}
@@ -0,0 +1,260 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/home/tag/video_tag_page.dart';
import 'package:hgdj/hj_page/video/view/video_detail_bottom_menu.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:hgdj/tools_base/widget/shrink_wrap.dart';
import 'package:video_player/video_player.dart';
import '../../../alert/video/share_media_dialog.dart';
import '../../../routers/jump_router.dart';
import '../../cartoon/cartoon_recommend_page.dart';
/// 视频播放页数据详情
class VideoDetailView extends StatefulWidget {
final VideoModel? model;
final VideoPlayerController? playCtr;
final Function(VideoModel model)? vmCallback;
const VideoDetailView({
super.key,
this.model,
this.playCtr,
this.vmCallback,
});
@override
State<VideoDetailView> createState() => _VideoDetailViewState();
}
class _VideoDetailViewState extends State<VideoDetailView>
with SingleTickerProviderStateMixin {
VideoModel? get videoModel => widget.model;
// 视频(SP)推荐按当前视频第一个 tag 拉同类;tags 为空则全局推荐
String? get _videoTagId =>
videoModel?.tags?.isNotEmpty == true ? videoModel?.tags?.first.id : null;
late final TabController tabCtr = TabController(length: 3, vsync: this);
final List<String> menuTitles = const ["视频推荐", "动漫推荐", "漫画推荐"];
@override
void dispose() {
tabCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ExtendedNestedScrollView(
onlyOneScrollInBody: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.symmetric(horizontal: 12),
child: _buildVideoInfo(),
),
),
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.fromLTRB(12, 18, 12, 0),
child:
VideoDetailBottomMenu(model: videoModel, onShare: _onShare),
),
),
SliverToBoxAdapter(
child: Container(
margin: EdgeInsets.fromLTRB(12, 18, 12, 18),
child: 0.5.line,
),
),
// 视频播放页广告
SliverToBoxAdapter(
child: AdsGridViewWidget(
6,
padding: EdgeInsets.only(left: 12, bottom: 18),
accordingAdsType: true,
),
),
SliverToBoxAdapter(child: _buildTitleMenu()),
];
},
body: TabBarView(
// 换播放源后 videoModel.id 变化 → 整个推荐区重建,按新视频刷新
key: ValueKey('rec_${videoModel?.id}'),
controller: tabCtr,
children: [
// 视频推荐
CartoonRecommendPage(
mediaStyle: MediaStyle.Video,
videoTagId: _videoTagId,
crossAxisCount: 2,
childAspectRatio: 168 / 142,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onVideoTap: _onVideoCellTap,
).keepAlive,
// 动漫推荐
CartoonRecommendPage(
mediaStyle: MediaStyle.Cartoon,
childAspectRatio: 111 / 174,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onAcgTap: _onAcgCellTap,
).keepAlive,
// 漫画推荐
CartoonRecommendPage(
mediaStyle: MediaStyle.Comics,
childAspectRatio: 111 / 174,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
onAcgTap: _onAcgCellTap,
).keepAlive,
],
),
);
}
/// 视频作品信息:标题 + 标签(原 VideoDetailInfoWidget 单处使用,已内联)
Widget _buildVideoInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRichText(),
_buildVideoTags(),
],
);
}
Widget _buildRichText() {
if (videoModel?.title?.trim().isNotEmpty != true) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
videoModel?.title?.trim() ?? "",
maxLines: 2,
style: const TextStyle(
color: Colors.white,
height: 1.5,
fontSize: 16,
fontWeight: FontWeight.w500),
),
);
}
Widget _buildVideoTags() {
if (videoModel?.tags?.isNotEmpty != true) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ShrinkWrap(
spacing: 0,
runSpacing: 6,
maxLines: 1,
children: videoModel!.tags!.map(_buildTagItem).toList(),
),
);
}
Widget _buildTagItem(TagsBean tag) {
return InkWell(
enableFeedback: false,
onTap: () {
widget.playCtr?.pause();
Get.to(() => VideoTagPage(tag), preventDuplicates: true);
},
child: Container(
padding: EdgeInsets.fromLTRB(8, 2, 8, 2),
margin: EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
"#${tag.name}",
style: TextStyle(
color: Color(0x73ffffff),
fontSize: 12,
),
),
),
);
}
/// 推荐 tab 标题栏(居中 TabBar + 渐变下划线,与漫画详情页一致)
Widget _buildTitleMenu() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TabBar(
controller: tabCtr,
isScrollable: true,
tabAlignment: TabAlignment.center,
padding: EdgeInsets.symmetric(horizontal: 8),
labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
labelColor: Color(0xE5FFFFFF),
unselectedLabelColor: Color(0x8CFFFFFF),
unselectedLabelStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
tabs: menuTitles
.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Text(e),
))
.toList(),
indicator: CustomIndicator(
isGradient: true,
width: 13,
height: 3,
borderRadius: BorderRadius.circular(1.5)),
),
],
);
}
/// 分享:弹分享面板
void _onShare() {
Get.dialog(ShareMediaDialog(videoModel: videoModel));
}
/// 视频推荐 cell 点击:无源地址/短片(<5min) → 暂停并 push 新页;
/// 同一视频 → toast;完整片 → 交 vmCallback 就地换源,不开新页
void _onVideoCellTap(VideoModel acModel) {
if (acModel.sourceURL?.isNotEmpty != true) {
widget.playCtr?.pause();
pushToVideoPage(videoModel: acModel);
return;
}
if (acModel.id == videoModel?.id) {
showToast("当前视频正在播放");
return;
}
// 300 秒 = 5 分钟:短片当预览片,开新页播;长片直接在当前播放器替换源
if ((acModel.playTime ?? 300) < 300) {
widget.playCtr?.pause();
pushToVideoPage(videoModel: acModel);
} else {
widget.vmCallback?.call(acModel);
}
}
/// 动漫/漫画 cell 点击:video 类型交 vmCallback 就地换源,其它跳漫画详情页
void _onAcgCellTap(CartoonMediaInfo acModel) {
widget.playCtr?.pause();
if (acModel.mediaType == 'video') {
// videoType=1 标记为动漫视频,让播放器走 cartoon 分支(区别于普通真人视频)
final vm = VideoModel(id: acModel.id)
..cover = acModel.coverH
..videoType = 1;
widget.vmCallback?.call(vm);
} else {
pushToCartoonPage(acModel);
}
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:video_player/video_player.dart';
import '../../../alert/video/buy_vip_alert.dart';
import '../../../hj_utils/pay/pay_manager.dart';
import '../../../tools_base/global_store/store.dart';
import '../../mine/mine_share/mine_share_page.dart';
import '../../mine/mine_vip/widgets/coin_pay_bottom_sheet.dart';
import '../../pre_sale/pre_sale_page.dart';
import '../../pre_sale/pre_sale_provider.dart';
/// 试看结束遮罩:金币解锁 / 观影券解锁 / 开通VIP(预售)
class VideoMaskBuyView extends StatefulWidget {
final VideoModel? model;
final VideoPlayerController? playCtr;
final VoidCallback? onBuySucc;
/// 遮罩主标题,默认「试看结束」
final String maskTitle;
const VideoMaskBuyView(
this.model, {
super.key,
this.playCtr,
this.onBuySucc,
this.maskTitle = '试看结束',
});
@override
State<VideoMaskBuyView> createState() => _VideoMaskBuyViewState();
}
class _VideoMaskBuyViewState extends State<VideoMaskBuyView> {
bool isBuying = false; // 下单中,防重复点击
bool _isUnlocking = false; // 解锁流程中(含弹窗展示),防连点叠多个弹窗
bool get hasPresale => presaleProvider.isOpen;
/// VIP 按钮文案:预售活动期内按预售流程走
String get vipBtnTitle {
if (!hasPresale) return "开通VIP免费看";
if (!presaleProvider.isPayFirst) return "开通预售免费看";
return presaleProvider.canPayBalance
? "支付尾款免费看"
: "请在${presaleProvider.startTimeMD}支付尾款";
}
/// 开通预售/VIP,回来后刷新按钮文案
Future<void> _onVipTap() async {
await (hasPresale
? Get.to(() => PreSalePage())
: BuyVipAlert.show(videoId: widget.model?.id));
if (mounted) setState(() {});
}
/// 金币解锁:用本地缓存余额立刻决策,避免 await 刷余额拖慢弹窗;
/// 不够 → 马上弹充值;够 → 直接下单。服务端 8000 仍会兜底弹窗。
Future<void> _onCoinUnlock({bool useCoupon = false}) async {
if (isBuying || _isUnlocking) return;
_isUnlocking = true;
try {
if (!useCoupon) {
// 后台刷新,不阻塞本次点击
globalStore.refreshWallet();
final need = widget.model?.realCoins ?? widget.model?.coins ?? 0;
if ((globalStore.wallet?.amount ?? 0) < need) {
await CoinPayBottomSheet.show();
if (mounted) setState(() {}); // 充值回来刷新余额展示
return;
}
}
await _buy(useCoupon: useCoupon);
} finally {
_isUnlocking = false;
}
}
/// 下单购买本片
Future<void> _buy({bool useCoupon = false}) async {
if (isBuying) return;
isBuying = true;
// 观影券抵扣:取能覆盖本片金币的券面额
final couponNum = useCoupon
? globalStore.meInfo?.couponGold(widget.model?.originCoins)
: null;
await PayManager().buy(
widget.model?.id,
ProductType.media,
source: 'video_mask',
goldVideoCouponNum: couponNum,
jumpWalletOnInsufficient: false,
onSuccess: (data) {
widget.model?.vidStatus?.hasPaid = true;
widget.playCtr?.play();
globalStore.updateUserInfo();
widget.onBuySucc?.call();
},
onFailure: (data) {
if (data?.code == 8000) CoinPayBottomSheet.show(); // 余额不足:不跳充值页,就地弹金币支付
},
);
isBuying = false;
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
// 动漫单集购买走 ComicBuyAlert,这里不显示遮罩
if (widget.model?.videoType == 1) return const SizedBox();
// 只算一次:longVideoStatus 内部 useFreePlay 会扣免费次数,多次调用会重复扣
final status = longVideoStatus(widget.model);
if (!status.isNeedPay) return const SizedBox();
final me = globalStore.meInfo;
return Container(
alignment: Alignment.center,
color: const Color.fromRGBO(0, 7, 18, 0.8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.maskTitle,
style: const TextStyle(
color: Colors.white, fontSize: 16, height: 1.5)),
18.sizeBoxH,
const Text("开通VIP 全站视频免费看",
style: TextStyle(color: Colors.white, fontSize: 16)),
18.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_btn(
title: status.isNeedBuy
? "${widget.model?.coins ?? 0}金币解锁"
: "邀请得3日VIP",
colors: const [Color(0xffFFE8BE), Color(0xffE6B764)],
textColor: const Color(0xff694923),
onTap: () => status.isNeedBuy
? _onCoinUnlock()
: Get.to(() => MineSharePage()),
),
18.sizeBoxW,
_btn(
title: vipBtnTitle,
colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)],
textColor: Colors.white,
onTap: _onVipTap,
),
],
),
// 有观影券才展示券解锁入口
if (me?.goldVideoCoupon?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(top: 16),
child: GestureDetector(
onTap: () => _onCoinUnlock(useCoupon: true),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text("使用观影券",
style: TextStyle(color: Colors.white, fontSize: 12)),
3.sizeBoxW,
Text(
"x${me?.couponCount ?? 0}",
style: const TextStyle(
color: Color(0xffE5365C), fontSize: 12),
),
],
),
),
),
],
),
);
}
/// 渐变胶囊按钮
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)),
),
);
}
}
+655
View File
@@ -0,0 +1,655 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:video_player/video_player.dart';
import '../../../hj_utils/buy_util.dart';
import '../../../hj_utils/date_time_util.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../tools_base/global_store/store.dart';
import '../../pre_sale/pre_sale_provider.dart';
import '../../short_video/view/video_progress_widget.dart';
import 'long_video_status.dart';
import 'video_status_view.dart';
//播放器控制层:操作台显隐、进度拖动、快进快退、倍速面板、长按倍速
class VideoMenuView extends StatefulWidget {
final VideoPlayerController playCtr;
final bool isFull;
final VideoModel? videoModel;
final VoidCallback? onFullScreen; //点全屏按钮
final VoidCallback? onBuyEvent; //点状态角标去购买
const VideoMenuView({
super.key,
required this.playCtr,
this.isFull = false,
this.videoModel,
this.onFullScreen,
this.onBuyEvent,
});
@override
State<VideoMenuView> createState() => _VideoMenuViewState();
}
class _VideoMenuViewState extends State<VideoMenuView> {
//倍速档位
static const speeds = [0.5, 1.0, 1.5, 2.0];
Timer? _hideTimer; //操作台3秒自动隐藏定时器
Timer? _skipTimer; //快进/快退提示1秒消失定时器
bool _showMenu = true; //操作台是否显示
bool _showSpeed = false; //倍速面板是否展开
bool _showSeekText = false; //是否显示中间拖动时间提示
bool _skipVisible = false; //快进/快退提示是否可见(驱动显隐动画)
int _skipDir = 0; // 1快进, -1 快退(仅记方向,淡出期间保留)
bool _isLongPress = false; // 长按2倍速
bool _isInited = false; //初始化仅一次的闩;中央图标 Obx 用到它但不订阅,靠 listener 里 setState 重建传导
Duration? _seekPos; //拖动中的目标进度
bool _wasPlaying = false; //拖动前是否在播放(松手后恢复)
(Duration?, Duration?) _seekRange = (null, null); //试看可拖区间,每次 build 重算一次
// playCtr 高频状态用 Rxlistener 直接赋值(Rx 自带去重、同值不通知),对应部位用 Obx 局部刷新——
// 免去手动维护比较字段,也不再播放时每帧全量 setState
final _playing = true.obs; //播放/暂停(按钮图标)
final _buffering = false.obs; //缓冲中(中央 loading 显隐)
final _posSec = 0.obs; //当前秒(试看锁定按秒判断)
VideoPlayerController get playCtr => widget.playCtr;
//当前播放倍速
double get curSpeed => playCtr.value.playbackSpeed;
/// 当前播的是预览片(未解锁 + 有预览地址)
bool get isPreview {
final model = widget.videoModel;
if (FreePlayManager().useFreePlay(model)) return false;
return model?.previewURL?.isNotEmpty == true &&
(needVip(model) || needCoin(model));
}
/// 未购买/未开通 VIP 时,松手后超出试看区间则回弹到边界
bool get _shouldLimitSeek {
final model = widget.videoModel;
if (model?.mediaInfo?.isCartoonFreeEpisode == true)
return false; // 动漫免费集:整条进度可拖,不锁试看区间
if (FreePlayManager().useFreePlay(model)) return false;
if (model?.freeArea == true) return false;
if (model?.vidStatus?.hasPaid == true) return false;
if (isPreview) return false;
if (coinFreeCount(model) >= 0) return false;
if (model?.isCoinVideo() == true) {
if (globalStore.isVIP && model?.coins == 0) return false;
if (model?.videoType != 1 && presaleProvider.coinVideoFreeCount >= 0)
return false;
}
final status = longVideoStatus(model);
return status.isNeedPay;
}
@override
void initState() {
super.initState();
playCtr.addListener(_onPlayerTick);
_wakeMenu();
}
@override
void dispose() {
_hideTimer?.cancel();
_skipTimer?.cancel();
playCtr.removeListener(_onPlayerTick);
_playing.close();
_buffering.close();
_posSec.close();
super.dispose();
}
//试看可拖动区间 [min,max]:仅 _shouldLimitSeek 时生效,否则 (null,null) 不限制
(Duration?, Duration?) _calcSeekRange() {
if (!_shouldLimitSeek) return (null, null);
final start = widget.videoModel?.previewStart ?? 0;
final free = widget.videoModel?.freeTime ?? 0;
return (Duration(seconds: start), Duration(seconds: start + free));
}
/// 试看锁定:需付费(区间非空)且 [sec] 已超出免费试看区间 → 禁止拖动
bool _isSeekLocked(int sec) =>
_seekRange.$1 != null && widget.videoModel?.isInFreeTime(sec) != true;
/// 松手落点:先夹到 [0,总时长],再夹回试看区间边界
Duration _snapSeek(Duration target) {
var ms = target.inMilliseconds;
final totalMs = playCtr.value.duration.inMilliseconds;
if (ms < 0) ms = 0;
if (totalMs > 0 && ms > totalMs) ms = totalMs;
var result = Duration(milliseconds: ms);
final (min, max) = _seekRange;
if (min != null && result < min) {
result = min;
} else if (max != null && result > max) {
result = max;
}
return result;
}
//整屏横滑:按位移换算目标进度(只更新预览,松手才真 seek)
void _dragSeek(Offset delta) {
if (_seekPos == null || !playCtr.value.isInitialized) return;
final totalMs = playCtr.value.duration.inMilliseconds;
var ms = _seekPos!.inMilliseconds + (800 * delta.dx).toInt();
if (ms < 0) {
ms = 0;
} else if (ms > totalMs) {
ms = totalMs;
}
_seekPos = Duration(milliseconds: ms);
if (mounted) setState(() {});
}
//唤出操作台并起3秒自动隐藏;skipHint 时额外起1秒快进/快退提示消失定时器
void _wakeMenu({bool skipHint = false}) {
if (!mounted) return;
_hideTimer?.cancel();
_showMenu = true;
setState(() {});
if (skipHint && _skipTimer == null) {
_skipTimer = Timer(const Duration(seconds: 1), () {
_skipVisible = false;
_skipTimer = null;
if (mounted) setState(() {});
});
}
_hideTimer = Timer(const Duration(seconds: 3), () {
_showMenu = false;
_showSpeed = false;
_skipVisible = false;
if (mounted) setState(() {});
});
}
//点击播放器:菜单已显示则收起(倍速面板展开时先收面板),否则唤出并重新计时
void _toggleMenu() {
if (!_showMenu) {
_playing.value = playCtr.value.isPlaying;
_wakeMenu();
return;
}
if (_showSpeed) {
_showSpeed = false;
_wakeMenu();
return;
}
_showMenu = false;
_hideTimer?.cancel();
setState(() {});
}
void _setPlay(bool play) {
_playing.value = play;
play ? playCtr.play() : playCtr.pause();
}
//双击左右1/3区域:快退/快进10秒
void _onDoubleTap(TapDownDetails details) {
final dx = details.globalPosition.dx;
final w = screen.screenWidth;
if (dx > w * 2 / 3) {
_skip(true);
} else if (dx < w / 3) {
_skip(false);
}
}
void _skip(bool forward) {
_skipDir = forward ? 1 : -1;
_skipVisible = true;
_wakeMenu(skipHint: true);
const step = Duration(seconds: 10);
final pos = playCtr.value.position;
playCtr.seekTo(_snapSeek(forward ? pos + step : pos - step));
}
void _onPlayerTick() {
if (!mounted) return;
final v = playCtr.value;
// Rx 自带去重,对应 Obx 自动局部刷新;进度条自己监听 controller,这里只把本组件要用的状态喂给 Rx,
// 不再每帧全量 setState、也不用手动比较
_playing.value = v.isPlaying;
_buffering.value = v.isBuffering;
_posSec.value = v.position.inSeconds; // 仅供试看锁定按秒判断
// 初始化仅发生一次:触发一次整体刷新,让 menuVisible 等非 Obx 部分更新
if (v.isInitialized && !_isInited) {
_isInited = true;
setState(() {});
}
}
@override
Widget build(BuildContext context) {
final menuVisible =
_showMenu || !_isInited || _isLongPress; //操作台/未初始化/长按倍速时可见
//试看区间每帧只算一次:_shouldLimitSeek 内含 useFreePlay 等带副作用的判断,不能每次拖动都重跑
_seekRange = _calcSeekRange();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleMenu,
onHorizontalDragStart: (_) {
//试看结束/未初始化禁止拖动
if (_isSeekLocked(playCtr.value.position.inSeconds) ||
!playCtr.value.isInitialized) return;
_wasPlaying = playCtr.value.isPlaying;
if (_wasPlaying) playCtr.pause();
_seekPos = playCtr.value.position;
_showSeekText = true;
setState(() {});
},
onHorizontalDragUpdate: (details) {
if (_isSeekLocked(playCtr.value.position.inSeconds) ||
!playCtr.value.isInitialized) return;
_wakeMenu();
_dragSeek(details.delta);
},
onHorizontalDragEnd: (_) async {
if (_seekPos != null) {
final target = _snapSeek(_seekPos!);
_seekPos = target;
await playCtr.seekTo(target);
}
_seekPos = null;
if (_wasPlaying) playCtr.play();
_showSeekText = false;
setState(() {});
_wakeMenu();
},
onHorizontalDragCancel: () {
_showSeekText = false;
setState(() {});
},
onDoubleTapDown: (detail) {
_showSpeed = false;
_onDoubleTap(detail);
},
onLongPressStart: (_) {
showToast("长按不动,2X倍速播放", gravity: ToastGravity.top);
_isLongPress = true;
_showSpeed = false;
_showSeekText = false;
setState(() {});
_wakeMenu();
playCtr.setPlaybackSpeed(2.0);
},
//抬手恢复1倍速(onLongPressUp 与 onLongPressEnd 必然同时触发,留一个即可)
onLongPressEnd: (_) {
_wakeMenu();
_isLongPress = false;
playCtr.setPlaybackSpeed(1.0);
},
child: IgnorePointer(
//隐藏时屏蔽点击
ignoring: !menuVisible,
child: AnimatedOpacity(
//操作台显隐淡入淡出
duration: const Duration(milliseconds: 200),
opacity: menuVisible ? 1 : 0,
child: SafeArea(
//全屏时操作层避开左右刘海与底部 home 条,避免按钮贴边误触
left: widget.isFull,
right: widget.isFull,
top: false,
bottom: widget.isFull,
child: Stack(
fit: StackFit.expand,
alignment: Alignment.center,
children: [
//状态角标:右上角,跟随操作台一起显隐
Positioned(
top: 12,
right: 16,
child: VideoStatusView(
model: widget.videoModel, onBuyEvent: widget.onBuyEvent),
),
Positioned(bottom: 0, left: 0, right: 0, child: _bottomMenu()),
Obx(() {
// 先读 Rx 再判断:_isInited 为 false 时 || 会短路,Rx 读不到会触发 Obx “未订阅” 报错
final buffering = _buffering.value;
final playing = _playing.value;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: (!_isInited || buffering || playing)
? const SizedBox(key: ValueKey('playIconEmpty'))
: _playIcon(),
);
}),
if (_showSeekText) _seekText(),
//倍速面板:从右滑入 / 向右滑出(隐藏时移出屏外并屏蔽点击)
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
top: 0,
bottom: 0,
right: _showSpeed ? 0 : -150,
child: IgnorePointer(
ignoring: !_showSpeed, child: _speedPanel()),
),
if (_skipDir != 0)
Positioned(
left: (_skipDir == 1) ? null : 0,
right: (_skipDir == 1) ? 0 : null,
child: IgnorePointer(
child: AnimatedScale(
scale: _skipVisible ? 1 : 0.85,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
child: AnimatedOpacity(
opacity: _skipVisible ? 1 : 0,
duration: const Duration(milliseconds: 220),
child: Container(
color: Colors.black.withValues(alpha: 0.5),
padding: EdgeInsets.fromLTRB(
(_skipDir == 1) ? 12 : 24,
12,
(_skipDir == 1) ? 24 : 12,
12),
child: _skipHint(),
),
),
),
),
),
],
),
),
),
),
);
}
//底部操作条:播放/暂停 + 进度条 + 倍速 + 全屏
Widget _bottomMenu() {
final (minSeek, maxSeek) = _seekRange;
return Container(
color: const Color(0xff04040a).withValues(alpha: 0.4),
//全屏底部加留白,避免滑杆贴屏幕边缘(Android 手势区)不好滑
padding: EdgeInsets.only(bottom: widget.isFull ? 10 : 0),
child: SizedBox(
height: 34,
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: () {
if (!playCtr.value.isInitialized) return;
_setPlay(!playCtr.value.isPlaying);
_wakeMenu();
},
child: Container(
height: 32,
width: 36,
padding: const EdgeInsets.symmetric(vertical: 5),
alignment: Alignment.center,
child: Obx(() {
final showPause = _playing.value; //播放中显示暂停图标
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: Image.asset(
showPause
? "pause_icon.webp".videoPath
: "play.webp".videoPath,
key: ValueKey(showPause),
width: 20.w,
),
);
}),
),
),
Expanded(
// enableSeek 随 _posSec(秒级)局部刷新:试看到点即禁止拖动滑杆
child: Obx(() {
// 先读 Rx:区间为 null 时 && 会短路,_posSec.value 读不到会触发 Obx “未订阅” 报错
final posSec = _posSec.value;
return VideoProgressWidget(
padding: EdgeInsets.zero,
controller: playCtr,
previewSeek: _seekPos, //整屏横滑拖动时,进度条 thumb/时间跟着 _seekPos 走
enableSeek: !_isSeekLocked(posSec), //试看结束禁止拖动滑杆
minSeekDuration: minSeek,
maxSeekDuration: maxSeek,
skipCallback: (duration) {
_seekPos = duration;
_showSeekText = true;
_wakeMenu();
},
gestureCallback: (value) {
if (value) {
_hideTimer?.cancel();
} else {
_seekPos = null; //滑杆拖动结束清预览,避免 previewSeek 残留
_showSeekText = false;
_wakeMenu();
}
},
);
}),
),
InkWell(
enableFeedback: false,
onTap: () {
_showSpeed = !_showSpeed;
_wakeMenu();
},
child: const Padding(
padding: EdgeInsets.fromLTRB(4, 6, 6, 6),
child: Text("倍速",
style: TextStyle(color: Colors.white, fontSize: 14)),
),
),
InkWell(
enableFeedback: false,
onTap: () => widget.onFullScreen?.call(),
child: Container(
height: 26,
width: 26,
margin: const EdgeInsets.only(right: 6),
child: Image.asset("full_icon.png".videoPath,
width: 26, height: 26),
),
),
],
),
),
);
}
//倍速面板
Widget _speedPanel() {
//空手势:吞掉面板上的横向拖动,避免穿透到底层触发视频快进/快退
return GestureDetector(
onHorizontalDragStart: (_) {},
onHorizontalDragUpdate: (_) {},
onHorizontalDragDown: (_) {},
onHorizontalDragCancel: () {},
onHorizontalDragEnd: (_) {},
child: ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
width: 113,
color: const Color(0xff1E1F1E).withValues(alpha: 0.6),
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: speeds.length,
separatorBuilder: (_, __) => Container(
height: 1, color: Colors.white.withValues(alpha: 0.05)),
itemBuilder: (_, i) => _speedItem(speeds[i]),
),
),
),
),
);
}
Widget _speedItem(double speed) {
final isSelected = curSpeed == speed;
return InkWell(
enableFeedback: false,
onTap: () {
if (!isSelected) playCtr.setPlaybackSpeed(speed);
_showSpeed = false;
setState(() {});
},
child: Container(
height: 42,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 33),
child: Text(
"$speed倍",
style: TextStyle(
color: isSelected ? const Color(0xffF9C089) : Colors.white, //选中档金色
fontSize: 14,
),
),
),
);
}
//中央播放按钮(暂停且非缓冲时才出现)
Widget _playIcon() {
return Center(
key: const ValueKey('playIcon'),
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTap: () {
_wakeMenu();
_setPlay(true);
},
child: Image.asset('circle_play.webp'.videoPath, width: 40, height: 40),
),
);
}
//拖动中的时间提示:当前 / 总时长
Widget _seekText() {
final showDuration = _seekPos ?? playCtr.value.position;
return Container(
padding: const EdgeInsets.only(bottom: 8),
alignment: Alignment.center,
child: Column(
children: [
//顶部偏移叠加安全区高度,避免竖屏被刘海/灵动岛遮挡
//注意:必须用 context 的 MediaQuery(外层 SafeArea 已吃掉 top 时这里就该是 0),Get.mediaQuery 拿的是根节点值会多顶 47
((widget.isFull ? 24 : 12) + MediaQuery.of(context).padding.top)
.sizeBoxH,
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
//半透明黑底:亮色画面上也能看清时间
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateTimeUtil.formatDuration(showDuration) ?? "",
style: const TextStyle(
color: AppColors.actionRed, fontSize: 14), //主题黄
),
if (playCtr.value.isInitialized)
Text(
" /${DateTimeUtil.formatDuration(playCtr.value.duration)}",
style: const TextStyle(color: Colors.white, fontSize: 14),
),
],
),
),
],
),
);
}
/// 快进/快退提示:文字 + 流动箭头(替代静态 >> <<
Widget _skipHint() {
final isForward = _skipDir == 1;
final text = Text(
isForward ? "快进10秒" : "快退10秒",
style:
TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13),
);
final arrows = _SeekArrows(forward: isForward);
return Row(
mainAxisSize: MainAxisSize.min,
children:
isForward ? [text, 4.sizeBoxW, arrows] : [arrows, 4.sizeBoxW, text],
);
}
}
/// 快进/快退的流动箭头动画:3 个三角箭头按相位依次点亮,形成流动感(替代静态 >> <<
class _SeekArrows extends StatefulWidget {
final bool forward; // true=快进(▶ 向右)false=快退(◀ 向左)
const _SeekArrows({required this.forward});
@override
State<_SeekArrows> createState() => _SeekArrowsState();
}
class _SeekArrowsState extends State<_SeekArrows>
with SingleTickerProviderStateMixin {
late final AnimationController _ctr = AnimationController(
vsync: this, duration: const Duration(milliseconds: 900))
..repeat();
@override
void dispose() {
_ctr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final arrow = widget.forward
? const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 18)
: const RotatedBox(
quarterTurns: 2,
child:
Icon(Icons.play_arrow_rounded, color: Colors.white, size: 18),
);
return AnimatedBuilder(
animation: _ctr,
builder: (_, __) => Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
// 波沿方向流动:快进 左→右、快退 右→左
final i = widget.forward ? index : (2 - index);
final phase = (_ctr.value + i / 3.0) % 1.0;
final opacity =
0.25 + 0.75 * (1 - (2 * phase - 1).abs()); // 三角波 0.25~1.0
// 压窄每个箭头的占位宽度,让三角波间距更紧凑
return SizedBox(
width: 10, child: Opacity(opacity: opacity, child: arrow));
}),
),
);
}
}
@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'long_video_status.dart';
const _tipPadding = EdgeInsets.fromLTRB(12, 3, 12, 3);
const _tipBg = BoxDecoration(
color: Color(0x99000000),
borderRadius: BorderRadius.all(Radius.circular(3)),
);
const _tipStyle = TextStyle(color: Colors.white, fontSize: 12);
/// 「已享VIP免费特权」专用提示,与 [VideoStatusView] 同位置(top 12/right 16)分工:
/// 那个在操作台内随其显隐,本 view 挂在 page 的 Stack 上不随操作台走,**必须自带 3 秒定时器**自己消失
class VipFreeTipView extends StatefulWidget {
final VideoModel? videoModel;
const VipFreeTipView({super.key, this.videoModel});
@override
State<VipFreeTipView> createState() => _VipFreeTipViewState();
}
class _VipFreeTipViewState extends State<VipFreeTipView> {
bool _isShowTip = true;
Timer? _timer;
/// 3 秒后隐藏提示。不在 initState 起:详情是异步加载的,首帧状态未必是 vipFree,
/// 要等状态真变成 vipFree 那次 build 才开始计时;`??=` 保证 build 多次也只起一个
void _startHideTimer() {
_timer ??= Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _isShowTip = false);
});
}
@override
Widget build(BuildContext context) {
if (!_isShowTip) return const SizedBox();
// 仅「已享VIP免费特权」走本 view,3 秒后自动隐藏;其余状态归 VideoStatusView
if (longVideoStatus(widget.videoModel).type !=
LongVideoStatusType.vipFree) {
return const SizedBox();
}
_startHideTimer();
return Container(
padding: _tipPadding,
decoration: _tipBg,
child: const Text('已享VIP免费特权', style: _tipStyle),
);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
/// 播放器操作台内的状态角标:显示状态文案,点击走购买/开会员
/// vipFree 不在此显示(归 [VipFreeTipView]),动漫的「跳过预览 / 金币免费特权」也不显示
class VideoStatusView extends StatelessWidget {
final VideoModel? model;
final Function? onBuyEvent;
const VideoStatusView({super.key, this.model, this.onBuyEvent});
@override
Widget build(BuildContext context) {
if (model == null) return const SizedBox.shrink();
final status = longVideoStatus(model);
final isCartoon = model?.videoType == 1;
final hideForCartoon = isCartoon &&
(status.type == LongVideoStatusType.skipPreview ||
status.type == LongVideoStatusType.coinFree);
if (hideForCartoon ||
status.type == LongVideoStatusType.none ||
status.type == LongVideoStatusType.vipFree) {
return const SizedBox();
}
return GestureDetector(
onTap: () => onBuyEvent?.call(),
child: Container(
padding: _tipPadding,
decoration: _tipBg,
child: Text(status.desc, style: _tipStyle),
),
);
}
}
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/video_download/download_button.dart';
import '../../../alert/video/video_line_menu_alert.dart';
import '../../../hj_model/splash/domain_source_model.dart';
/// 播放页「简介/评论」切换栏:原生 TabBar(下划线指示器跟随 TabBarView 滑动连续移动) + 右侧线路切换/下载
class VideoTabbarMenuWidget extends StatefulWidget {
final TabController tabCtr;
final VideoModel? model;
final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播
const VideoTabbarMenuWidget(
this.tabCtr, {
this.model,
this.onSwitchLine,
super.key,
});
@override
State<StatefulWidget> createState() {
return _VideoTabbarMenuWidgetState();
}
}
class _VideoTabbarMenuWidgetState extends State<VideoTabbarMenuWidget> {
TabController get tabCtr => widget.tabCtr;
VideoModel? get videoModel => widget.model;
@override
Widget build(BuildContext context) {
Domain? selectedCnd;
try {
selectedCnd = Address.cdnAddressLists
.firstWhere((element) => element.url == Address.cdnAddress);
} catch (e) {
debugLog(e);
}
return SizedBox(
height: 44,
child: Column(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TabBar(
controller: tabCtr,
isScrollable: true,
tabAlignment: TabAlignment.start,
padding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
labelColor: const Color(0xE5FFFFFF),
unselectedLabelColor: const Color(0x73FFFFFF),
labelStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
unselectedLabelStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w400),
indicatorSize: TabBarIndicatorSize.label,
dividerHeight: 0, // 去掉 TabBar 默认底部分割线(外层已有 0.5.line)
indicator: CustomIndicator(
color: AppColors.actionRed,
width: 16,
height: 4,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(3)),
),
tabs: [
const Tab(text: "简介"),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text("评论"),
const SizedBox(width: 2),
Text(
videoModel?.commentCount?.countStr ?? '0',
style: const TextStyle(
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w400,
color: Color(0xff989898),
),
),
],
),
),
],
),
),
// 简介/评论带下划线需贴底,线路切换和下载单独包一层填满高度后垂直居中
SizedBox(
height: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
/// 线路切换
InkWell(
enableFeedback: false,
onTap: () async {
if (await VideoLineMenuAlert.show())
widget.onSwitchLine?.call();
setState(() {});
},
child: Row(
children: [
Image.asset("line_switch.webp".videoPath,
width: 16),
2.sizeBoxW,
Text(
selectedCnd?.desc ?? '',
style: const TextStyle(
color: Color(0xff989898),
fontSize: 12,
),
),
],
),
),
12.sizeBoxW,
DownloadButton(
key: ValueKey(videoModel?.id),
video: videoModel,
isShort: false,
),
],
),
),
12.sizeBoxW,
],
),
),
0.5.line,
],
),
);
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../../hj_model/splash/domain_source_model.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../main_page/provider/msg_provider.dart';
import '../../mine/mine_vip/pay_order_source.dart';
import '../../mine/mine_vip/vip_product_manager.dart';
import '../../pre_sale/limit_time_provider.dart';
//播放页横幅:限时活动 / 支付分层横幅
class VipPromoBanner extends StatefulWidget {
final VideoPlayerController? playCtr;
final VideoModel? videoModel;
final EdgeInsetsGeometry? margin;
final bool isFromHome;
const VipPromoBanner({
super.key,
this.playCtr,
this.videoModel,
this.margin,
this.isFromHome = false,
});
@override
State<StatefulWidget> createState() {
return _VipPromoBanner();
}
}
class _VipPromoBanner extends State<VipPromoBanner> {
@override
Widget build(BuildContext context) {
return Consumer2<MineMsgProvider, LimitTimeProvider>(
builder: (context, newser, limitTime, child) {
// 1. 限时活动优先
if (limitTime.canShow) return LimitTimeBanner();
// 2. 支付分层横幅(playPage 图 + vipCard 跳转),都没有则不展示
final layeredConfig = MineMsgProvider().payTier?.config;
if ((layeredConfig?.playPage ?? '').isNotEmpty)
return _buildLayeredBanner(layeredConfig!);
return const SizedBox();
},
);
}
/// 支付分层横幅:用后端 playPage 图,点击弹会员弹窗(默认选中 vipCard),倒计时按 lastDiscountTime
Widget _buildLayeredBanner(PayTierConfig config) {
return InkWell(
enableFeedback: false,
onTap: () async {
// 与首页分层弹窗 GuideHomeDialog 一致:暂停播放 → 直接拉起 vipCard 支付方式弹窗 → 刷新用户信息
widget.playCtr?.pause();
await vipProductManager.payByVipCard(
config.vipCard,
reportAnalytics: false, // 视频底部分层 banner:只拉支付,不上报 VIP 卡皮事件
orderTrack: PayOrderTrackInfo(
sourcePage: PaySourcePage.videoBottomBanner,
sourceRef: widget.videoModel?.id,
videoId: widget.videoModel?.id,
),
);
await globalStore.updateUserInfo();
if (mounted) setState(() {});
},
child: Container(
height: 46,
width: screen.screenWidth,
margin: widget.margin,
child: Stack(
fit: StackFit.expand,
children: [
// 分层横幅图(后端加密图)
NetworkImageLoader(
imageUrl: config.playPage ?? "",
fit: BoxFit.fill,
borderRadius: 0),
// 分层倒计时:监听 tick 每秒刷新,过期自动收起
ValueListenableBuilder<int>(
valueListenable: MineMsgProvider().tick,
builder: (_, __, ___) => config.hasDiscountCountdown
? _buildLayeredCountdown(config)
: const SizedBox.shrink(),
),
],
),
),
);
}
/// 分层倒计时行(时分秒块叠在 playPage 图上,位置按后端图设计,不对就调 sizeBoxW)
Widget _buildLayeredCountdown(PayTierConfig config) {
return Row(
children: [
162.sizeBoxW,
_layeredTimeItem(config.discountHour),
_layeredColon(),
_layeredTimeItem(config.discountMin),
_layeredColon(),
_layeredTimeItem(config.discountSec),
const Spacer(),
],
);
}
Widget _layeredTimeItem(String value) {
return Container(
width: 22,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xff1B1B1B),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: const Color(0xffFFE381), width: 0.5),
),
child: Text(value,
style: const TextStyle(color: Colors.white, fontSize: 12)),
);
}
Widget _layeredColon() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 5),
child: Text(":", style: TextStyle(color: Colors.white, fontSize: 12)),
);
}