初始化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 高频状态用 Rx:listener 直接赋值(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)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user