初始化
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
import '../../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../../hj_model/cartoon_media_info.dart';
|
||||
import '../../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../cartoon/widget/free_badge.dart';
|
||||
import '../../live/live_widget.dart';
|
||||
|
||||
class CartoonEpisodeMenuAlert extends StatefulWidget {
|
||||
final ComicChapterInfo? curModel;
|
||||
final CartoonMediaInfo? cartoonModel;
|
||||
final Function(ComicChapterInfo)? callback;
|
||||
|
||||
CartoonEpisodeMenuAlert(this.curModel,
|
||||
{super.key, this.callback, this.cartoonModel});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _CartoonEpisodeMenuAlertState();
|
||||
}
|
||||
}
|
||||
|
||||
class _CartoonEpisodeMenuAlertState extends State<CartoonEpisodeMenuAlert> {
|
||||
CartoonMediaInfo? get cartoonModel => widget.cartoonModel;
|
||||
RefreshController? refreshCtr;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
void _loadMoreData() async {
|
||||
if (_isLoading) return;
|
||||
_isLoading = true;
|
||||
try {
|
||||
final currentPage = cartoonModel?.episodeCurPage ?? 0;
|
||||
final episodeModel = await ACGService.getChapterList(
|
||||
cartoonModel?.id ?? "",
|
||||
currentPage + 1,
|
||||
cartoonModel?.episodePageSize ?? 40,
|
||||
sortType: cartoonModel?.isUpSort,
|
||||
);
|
||||
if ((currentPage + 1) == 1) {
|
||||
cartoonModel?.episodeList = episodeModel?.list;
|
||||
} else {
|
||||
cartoonModel?.episodeList?.addAll(episodeModel?.list ?? []);
|
||||
}
|
||||
cartoonModel?.episodeCurPage = currentPage + 1;
|
||||
cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true;
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
_isLoading = false;
|
||||
//异步返回时 sheet 可能已关闭,避免对已卸载组件 setState / 操作已释放的 refreshCtr
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
cartoonModel?.haxNextEpisode == true
|
||||
? refreshCtr?.loadComplete()
|
||||
: refreshCtr?.loadNoData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(18.w, 21, 18.w, 32),
|
||||
height: 460,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
|
||||
color: Color(0xff0F0F0F)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SheetHandleBar(color: Color(0x4DFFFFFF)),
|
||||
18.h.sizeBoxH,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"剧集列表",
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
18.h.sizeBoxH,
|
||||
Expanded(
|
||||
child: pullYsRefresh(
|
||||
onInit: (ctr) => refreshCtr = ctr,
|
||||
enablePullDown: false,
|
||||
onLoading: (refreshCtr) => _loadMoreData(),
|
||||
noDataText: "",
|
||||
child: GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: cartoonModel?.episodeList?.length ?? 0,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
ComicChapterInfo model = cartoonModel!.episodeList![index];
|
||||
bool isSelected = model.id == widget.curModel?.id;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (!isSelected) {
|
||||
widget.callback?.call(model);
|
||||
Get.back();
|
||||
}
|
||||
},
|
||||
child: CartoonChapterItem(
|
||||
model,
|
||||
isSelected: isSelected,
|
||||
fatherM: cartoonModel!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CartoonChapterItem extends StatelessWidget {
|
||||
final ComicChapterInfo model;
|
||||
final CartoonMediaInfo fatherM;
|
||||
final bool isSelected;
|
||||
final bool canPlay;
|
||||
|
||||
const CartoonChapterItem(this.model,
|
||||
{super.key,
|
||||
this.isSelected = false,
|
||||
this.canPlay = true,
|
||||
required this.fatherM});
|
||||
|
||||
// 前 N 集免费(当前集号 <= freeEpisode)且整本无任何权限时,展示「免费」角标
|
||||
bool get _isFree =>
|
||||
fatherM.hasPermission == false &&
|
||||
fatherM.isFreeEpisodeNumber(model.episodeNumber);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Color(0x1A6975FF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.actionRed : Color(0x1AFFFFFF),
|
||||
width: 1),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
//前 N 集免费且整本无权限 → 左上角「免费」角标
|
||||
if (_isFree)
|
||||
const Positioned(top: 0, left: 0, child: FreeBadge(isCorner: true)),
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"${model.episodeNumber}",
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppColors.actionRed : Color(0xE5FFFFFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (isSelected && canPlay) ...[
|
||||
2.sizeBoxW,
|
||||
AudioWaveView(
|
||||
color: AppColors.actionRed,
|
||||
height: 10,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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/video_model.dart';
|
||||
import 'package:hgdj/hj_page/home/tag/cartoon_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/routers/jump_router.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 '../../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../../hj_model/cartoon_media_info.dart';
|
||||
import '../../cartoon/cartoon_recommend_page.dart';
|
||||
import 'cartoon_episode_menu_alert.dart';
|
||||
|
||||
/// 动漫视频详情页 tab 第 0 页:作品信息 + 选集 + 推荐
|
||||
class VideoDetailCartoonView extends StatefulWidget {
|
||||
final VideoModel? model;
|
||||
final ComicChapterInfo? currentEpisode;
|
||||
final VideoPlayerController? playCtr;
|
||||
final Function(VideoModel model)? vmCallback;
|
||||
final Function(ComicChapterInfo model)? episoCallback; // 动漫选集回调
|
||||
|
||||
const VideoDetailCartoonView({
|
||||
super.key,
|
||||
this.model,
|
||||
this.playCtr,
|
||||
this.vmCallback,
|
||||
this.currentEpisode,
|
||||
this.episoCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoDetailCartoonView> createState() => _VideoDetailCartoonViewState();
|
||||
}
|
||||
|
||||
class _VideoDetailCartoonViewState extends State<VideoDetailCartoonView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
VideoModel? get videoModel => widget.model;
|
||||
|
||||
// 当前内容是动漫,动漫推荐按本作 acg tag(mediaInfo.tagDetails) 拉相似作品
|
||||
String? get _acgTagId => videoModel?.mediaInfo?.tagDetails?.firstOrNull?.id;
|
||||
|
||||
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(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: _buildCartoonInfo(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 18),
|
||||
margin: EdgeInsets.symmetric(horizontal: 12),
|
||||
child:
|
||||
VideoDetailBottomMenu(model: videoModel, onShare: _onShare),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(12, 18, 12, 18),
|
||||
child: 0.5.line,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: VideoCartoonEpisodeMenu(
|
||||
widget.currentEpisode,
|
||||
cartoonModel: videoModel?.mediaInfo,
|
||||
callback: widget.episoCallback,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildTitleMenu()),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
// 换播放源后 videoModel.id 变化 → 整个推荐区重建,按新视频刷新
|
||||
key: ValueKey('rec_${videoModel?.id}'),
|
||||
controller: tabCtr,
|
||||
children: [
|
||||
// 动漫推荐(当前内容同类,带本作 tagId 拉相似)
|
||||
CartoonRecommendPage(
|
||||
mediaStyle: MediaStyle.Cartoon,
|
||||
mediaId: _acgTagId,
|
||||
childAspectRatio: 111 / 174,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
onAcgTap: _onAcgCellTap,
|
||||
).keepAlive,
|
||||
// 视频推荐(非当前内容类型,不带 tagId)
|
||||
CartoonRecommendPage(
|
||||
mediaStyle: MediaStyle.Video,
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 168 / 142,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
onVideoTap: _onVideoCellTap,
|
||||
).keepAlive,
|
||||
// 漫画推荐
|
||||
CartoonRecommendPage(
|
||||
mediaStyle: MediaStyle.Comics,
|
||||
childAspectRatio: 111 / 174,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
onAcgTap: _onAcgCellTap,
|
||||
).keepAlive,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分享:弹分享面板
|
||||
void _onShare() {
|
||||
Get.dialog(ShareMediaDialog(videoModel: videoModel));
|
||||
}
|
||||
|
||||
/// 动漫作品信息:标题 + 标签(原 VideoDetailCartoonInfoWidget 单处使用,已内联)
|
||||
Widget _buildCartoonInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
_buildRichText(),
|
||||
_buildCartoonTags(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRichText() {
|
||||
if (videoModel?.title?.trim().isNotEmpty == true) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(bottom: 0),
|
||||
child: Text(
|
||||
videoModel?.title?.trim() ?? "",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
height: 1.5,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox();
|
||||
}
|
||||
|
||||
Widget _buildCartoonTags() {
|
||||
if (videoModel?.tags?.isNotEmpty == true) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: ShrinkWrap(
|
||||
spacing: 0,
|
||||
runSpacing: 6,
|
||||
maxLines: 1,
|
||||
children: videoModel!.tags!.map((e) => _buildTagItem(e)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
Widget _buildTagItem(TagsBean tag) {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () {
|
||||
widget.playCtr?.pause();
|
||||
Get.to(() => CartoonTagPage(title: tag.name ?? "", sId: tag.id),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 视频推荐 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 选集组件:横向 episode 列表 + 「选集」按钮(点击弹 bottomSheet 全集)
|
||||
class VideoCartoonEpisodeMenu extends StatelessWidget {
|
||||
final CartoonMediaInfo? cartoonModel;
|
||||
final ComicChapterInfo? currentEpisode;
|
||||
final Function(ComicChapterInfo model)? callback;
|
||||
|
||||
const VideoCartoonEpisodeMenu(
|
||||
this.currentEpisode, {
|
||||
super.key,
|
||||
this.cartoonModel,
|
||||
this.callback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
// StatefulBuilder:bottomSheet 关闭后强制刷新选中态,
|
||||
// 避免父级 callback 异步、选中样式延迟更新
|
||||
child: StatefulBuilder(builder: (ctx, states) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
await Get.bottomSheet(CartoonEpisodeMenuAlert(
|
||||
currentEpisode,
|
||||
cartoonModel: cartoonModel,
|
||||
callback: (index) => callback?.call(index),
|
||||
));
|
||||
states(() {});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"选集",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Color(0xE5FFFFFF),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
"全${cartoonModel?.totalEpisode ?? 1}话",
|
||||
style: TextStyle(fontSize: 12, color: Color(0xff989898)),
|
||||
),
|
||||
3.sizeBoxW,
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
color: Color(0xff989898),
|
||||
size: 13,
|
||||
),
|
||||
16.sizeBoxW,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 58,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: cartoonModel?.episodeList?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final model = cartoonModel!.episodeList![index];
|
||||
return Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
child: GestureDetector(
|
||||
onTap: () => callback?.call(model),
|
||||
child: CartoonChapterItem(
|
||||
model,
|
||||
isSelected: currentEpisode?.id == model.id,
|
||||
fatherM: cartoonModel!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_utils/video_view_type.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../tools_base/debug_log.dart';
|
||||
|
||||
class SimpleVideoPlayerLogic extends GetxController {
|
||||
final String videoUrl;
|
||||
final String? localPath;
|
||||
|
||||
SimpleVideoPlayerLogic({required this.videoUrl, this.localPath});
|
||||
|
||||
VideoPlayerController? videoCtr;
|
||||
StreamSubscription? pauseSub;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露
|
||||
pauseSub = eventBus.on<PauseVideoEvent>((_) => videoCtr?.pause());
|
||||
}
|
||||
|
||||
@override
|
||||
void onReady() {
|
||||
super.onReady();
|
||||
_initData();
|
||||
}
|
||||
|
||||
void _initData({bool isRetry = false}) async {
|
||||
try {
|
||||
if (localPath?.isNotEmpty == true) {
|
||||
videoCtr = PlayerFactory.file(localPath);
|
||||
} else {
|
||||
debugLog('url=$videoUrl');
|
||||
videoCtr = PlayerFactory.network(videoUrl);
|
||||
}
|
||||
await videoCtr?.initialize();
|
||||
if (isRetry)
|
||||
confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地
|
||||
videoCtr?.play();
|
||||
update();
|
||||
} catch (e) {
|
||||
debugLog("simple视频初始化失败:$e url = $videoUrl");
|
||||
// 首次芯片解码/渲染报错:仅内存切 platformView 重试当前视频,成功后才落本地
|
||||
if (isDecoderError(e) && switchToPlatformView()) {
|
||||
videoCtr?.dispose();
|
||||
_initData(isRetry: true);
|
||||
return;
|
||||
}
|
||||
showToast("视频加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
pauseSub?.cancel();
|
||||
videoCtr?.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_menu_view.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import 'simple_video_player_logic.dart';
|
||||
import 'video_full_page.dart';
|
||||
|
||||
class SimpleVideoPlayerPage extends StatelessWidget {
|
||||
final String videoUrl;
|
||||
final String title;
|
||||
final String? localPath;
|
||||
|
||||
const SimpleVideoPlayerPage(
|
||||
{super.key, required this.videoUrl, required this.title, this.localPath});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<SimpleVideoPlayerLogic>(
|
||||
init: SimpleVideoPlayerLogic(videoUrl: videoUrl, localPath: localPath),
|
||||
builder: (logic) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(child: _player(logic.videoCtr)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _player(VideoPlayerController? ctr) {
|
||||
if (ctr?.value.isInitialized != true) return LoadingCenterWidget();
|
||||
return AspectRatio(
|
||||
aspectRatio: ctr!.value.aspectRatio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
VideoPlayer(ctr),
|
||||
VideoMenuView(
|
||||
playCtr: ctr,
|
||||
onFullScreen: () => Get.to(() => VideoFullPage(playCtr: ctr)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// 全屏播放页业务逻辑:横竖屏切换 + 后台自动暂停
|
||||
/// 注:[playCtr] 由外部页面持有并释放,本类只借用,绝不能 dispose
|
||||
class VideoFullLogic extends GetxController with WidgetsBindingObserver {
|
||||
VideoFullLogic({required this.playCtr, this.isAutoV = true});
|
||||
|
||||
final VideoPlayerController playCtr;
|
||||
final bool isAutoV; // true:按视频比例决定是否转横屏;false:强制转横屏
|
||||
|
||||
double get aspectRatio => playCtr.value.aspectRatio;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 横向视频(或调用方强制)才转横屏,竖屏视频保持竖屏全屏
|
||||
if (aspectRatio > 1 || !isAutoV) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom]);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
}
|
||||
// 转屏后 Get.width/height 才是新值,下一帧刷一次让画面按新尺寸铺满
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => update());
|
||||
}
|
||||
|
||||
/// 切后台暂停,防止息屏/切走后声音还在外放
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state == AppLifecycleState.paused) playCtr.pause();
|
||||
}
|
||||
|
||||
/// 退出全屏:恢复竖屏 + 状态栏,再退页
|
||||
void exitFullScreen() {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top]);
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
Get.back();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.onClose(); // playCtr 属于调用方,这里不释放
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/video/video_full_logic.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_menu_view.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/screen.dart';
|
||||
import '../../tools_base/unique_tag_mixin.dart';
|
||||
|
||||
/// 全屏播放页(复用外部传入的播放器控制器,不新建也不释放)
|
||||
class VideoFullPage extends StatefulWidget {
|
||||
final VideoPlayerController playCtr;
|
||||
final VideoModel? videoModel;
|
||||
final bool isAutoV; // 判断size,自动转竖屏
|
||||
final bool showMenu; //是否需要显示菜单栏,直播不需要展示菜单栏
|
||||
|
||||
const VideoFullPage({
|
||||
super.key,
|
||||
required this.playCtr,
|
||||
this.videoModel,
|
||||
this.isAutoV = true,
|
||||
this.showMenu = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoFullPage> createState() => _VideoFullPageState();
|
||||
}
|
||||
|
||||
// tag 隔离:logic 持有外部传入的 playCtr。不加 tag 时上一个全屏页的 Get.delete 若没跑完,
|
||||
// GetBuilder 的 init 会被跳过 → 复用旧 logic、播到上一个视频
|
||||
class _VideoFullPageState extends State<VideoFullPage> with UniqueTagMixin {
|
||||
VideoPlayerController get playCtr => widget.playCtr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<VideoFullLogic>(
|
||||
tag: uniqueTag,
|
||||
init: VideoFullLogic(playCtr: playCtr, isAutoV: widget.isAutoV),
|
||||
builder: (logic) => PopScope(
|
||||
// 系统返回键/侧滑不直接退页,先恢复竖屏再由 exitFullScreen 退
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
logic.exitFullScreen();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 撑满 Stack 用(Stack 尺寸取最大的非 Positioned 子节点)。
|
||||
// 不能改成 StackFit.expand:那会给 AspectRatio 传 tight 约束,视频比例失效
|
||||
SizedBox(width: screen.screenWidth, height: screen.screenHeight),
|
||||
Hero(
|
||||
tag: "player",
|
||||
child: AspectRatio(
|
||||
aspectRatio: logic.aspectRatio,
|
||||
child: VideoPlayer(playCtr),
|
||||
),
|
||||
),
|
||||
if (widget.showMenu) _menuLayer(logic),
|
||||
_backBtn(logic),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放器控制层(进度条/倍速/快进快退)
|
||||
Widget _menuLayer(VideoFullLogic logic) {
|
||||
return Positioned(
|
||||
bottom: 5,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SizedBox(
|
||||
width: screen.screenWidth,
|
||||
height: screen.screenHeight,
|
||||
child: VideoMenuView(
|
||||
playCtr: playCtr,
|
||||
isFull: true,
|
||||
onFullScreen: logic.exitFullScreen,
|
||||
videoModel: widget.videoModel,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 全屏(横屏)下返回按钮避开刘海/状态栏安全区(原来用竖屏的 paddingTop 不准)
|
||||
Widget _backBtn(VideoFullLogic logic) {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: logic.exitFullScreen,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.fromLTRB(5, 5, 15, 15),
|
||||
child: SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Icon(Icons.arrow_back_ios_sharp, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_page/video/video_full_page.dart';
|
||||
import 'package:hgdj/hj_page/video/view/long_video_status.dart';
|
||||
import 'package:hgdj/hj_utils/codec_support.dart';
|
||||
import 'package:hgdj/hj_utils/const.dart';
|
||||
import 'package:hgdj/hj_utils/play_diagnose.dart';
|
||||
import 'package:hgdj/hj_utils/video_view_type.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../alert/video/buy_vip_alert.dart';
|
||||
import '../../alert/video/comic_buy_alert.dart';
|
||||
import '../../alert/vip_guide/guide_bottom_sheet.dart';
|
||||
import '../../alert/vip_guide/guide_common.dart';
|
||||
import '../../alert/vip_guide/guide_config.dart';
|
||||
import '../../alert/vip_guide/guide_countdown_dialog.dart';
|
||||
import '../../alert/vip_guide/guide_free_trial_sheet.dart';
|
||||
import '../../alert/vip_guide/guide_manager.dart';
|
||||
import '../../alert/vip_guide/timed_popup_manager.dart';
|
||||
import '../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../hj_model/cartoon_media_info.dart';
|
||||
import '../../hj_model/media_content.dart';
|
||||
import '../../hj_model/splash/ads_model.dart';
|
||||
import '../../hj_model/splash/domain_source_model.dart';
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../hj_utils/api_service/vid_service.dart';
|
||||
import '../../hj_utils/free_play_manager.dart';
|
||||
import '../../hj_utils/history_util.dart';
|
||||
import '../../routers/jump_router.dart';
|
||||
import '../../tools_base/ad_manager.dart';
|
||||
import '../../tools_base/debug_log.dart';
|
||||
import '../../tools_base/event_bus/event_bus_util.dart';
|
||||
import '../../tools_base/event_bus/events.dart';
|
||||
import '../../tools_base/global_store/store.dart';
|
||||
import '../main_page/provider/msg_provider.dart';
|
||||
import '../pre_sale/pre_sale_provider.dart';
|
||||
|
||||
/// 视频/动漫播放页业务逻辑:拉详情 → 初始化播放器;动漫多一步章节列表
|
||||
class VideoLogic extends GetxController with GetSingleTickerProviderStateMixin {
|
||||
VideoLogic({this.vid, this.isCartoon = false});
|
||||
|
||||
/// 当前播的是不是动漫。**可变**:页内切内容后按被点内容的 videoType 重定(见 [switchVideo]),
|
||||
/// 走错接口会「视频资源加载失败」。页内所有判断都读它,不看进页面时的入参
|
||||
bool isCartoon;
|
||||
final String? vid;
|
||||
VideoModel? videoModel;
|
||||
|
||||
/// 当前选定的播放 URL(按 playContentStyle / 预览权 / 265能力等条件选择,见 [_initPlayer])
|
||||
String? videoUrl;
|
||||
|
||||
String? get videoCover => videoModel?.cover;
|
||||
VideoPlayerController? playerCtr;
|
||||
|
||||
/// 当前是否在播 H.265 源。**必须选流当下记录**,不能用 `videoUrl == realH265Url` 派生——
|
||||
/// realH265Url 拿全局 token/cdn 现拼,刷新后对不上会误判 false,265 降级链就失效了
|
||||
bool isPlayingH265 = false;
|
||||
|
||||
/// 本条视频是否已回退 264(仅内存)。初始化失败或播放中报错时置位,
|
||||
/// 之后所有重建都走 264;换视频时 loadData 复位
|
||||
bool _forceH264 = false;
|
||||
|
||||
/// 贴片广告列表(position=50)
|
||||
List<AdsInfoModel> adsList = [];
|
||||
|
||||
/// 预览结尾 VIP 弹窗的去重标记,防止重复弹
|
||||
bool isShowDialog = false;
|
||||
bool isShowAd = false; // true: 显示视频广告
|
||||
bool isFullScreen = false; // true:视频进入全屏页面
|
||||
RxBool isShowBuy = false.obs; // 显示购买和vip弹窗
|
||||
|
||||
late final TabController tabCtr =
|
||||
TabController(length: 2, vsync: this); // 简介/评论
|
||||
CartoonMediaInfo? cartoonModel; //动漫
|
||||
ComicChapterInfo? currentEpisode; //动漫集数
|
||||
|
||||
/// onClose 后的标记,用于防止异步回调里再操作已释放控制器
|
||||
bool isDispose = false;
|
||||
LongVideoStatus get videoStatus => longVideoStatus(videoModel);
|
||||
|
||||
/// 「金币视频免看」权益:VIP+coins==0 或 剩余免次数>=0 才免看;非金币视频/动漫不享受
|
||||
bool get isVipCoinFree {
|
||||
if (videoModel?.isCoinVideo() != true) {
|
||||
// 非金币视频
|
||||
return false;
|
||||
}
|
||||
if (globalStore.isVIP && videoModel?.coins == 0) {
|
||||
// vip金币视频 免看
|
||||
return true;
|
||||
} else {
|
||||
if (isCartoon == true) return false; // 动漫视频不享受金币免次数权益
|
||||
if (presaleProvider.coinVideoFreeCount >= 0) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isInited => playerCtr?.value.isInitialized == true; //视频控制器是否加载
|
||||
double get normalRatio => 16 / 9;
|
||||
|
||||
/// 实际播放比例:动漫和未初始化都用 16:9 兜底,否则用控制器报告的实际比例
|
||||
double get videoRatio =>
|
||||
(!isCartoon && isInited) ? playerCtr!.value.aspectRatio : normalRatio;
|
||||
|
||||
StreamSubscription? _pauseVideoSub;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
loadData();
|
||||
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露
|
||||
_pauseVideoSub = eventBus.on<PauseVideoEvent>((_) => playerCtr?.pause());
|
||||
super.onInit();
|
||||
}
|
||||
|
||||
/// 支付分层首弹:不分新人/老人,全局只弹一次(只看是否弹过,不再判断配置卡)
|
||||
void alertVipFirst() async {
|
||||
if (await MineMsgProvider.isPayPopupShown()) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isDispose) return;
|
||||
BuyVipAlert.show(videoId: videoModel?.id);
|
||||
MineMsgProvider.markPayPopupShown();
|
||||
});
|
||||
}
|
||||
|
||||
/// 新用户(newUnpay)用免费次数观看的视频,进播放页停留满5分钟弹一次付费引导。
|
||||
/// 仅首次进入页面计时,页面内切集/换源不重启(一次页面只弹一次);计时/校验/取消交给 TimedPopupManager。
|
||||
void _startFreeWatchTimer() {
|
||||
TimedPopupManager().schedule(
|
||||
type: TimedPopupType.freeWatchPay,
|
||||
delay: const Duration(minutes: 5),
|
||||
//到点校验:场景开关开着、仅长视频、新用户、未开会员、仍在用免费次数观看
|
||||
//按到点时在播的内容判断:中途切到动漫就不弹了
|
||||
canShow: () =>
|
||||
!isDispose &&
|
||||
!isCartoon &&
|
||||
!globalStore.isVIP &&
|
||||
GuideManager().canShow(GuideFreeTrialSheet.scene) &&
|
||||
MineMsgProvider().payTier?.status == PayTier.newUnpay &&
|
||||
FreePlayManager().useFreePlay(videoModel) == true &&
|
||||
GuideFreeTrialSheet
|
||||
.isCountLow, //必须排在 useFreePlay 之后:它会先扣一次,扣完才是该显示的剩余数
|
||||
onShow: () async {
|
||||
// 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下
|
||||
if (await loadGuideCard(GuideFreeTrialSheet.scene) == null || isDispose)
|
||||
return;
|
||||
// 只暂停本来在播的:用户自己停在那看简介/评论时,弹完别擅自续播
|
||||
final wasPlaying = playerCtr?.value.isPlaying == true;
|
||||
if (wasPlaying)
|
||||
playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update()
|
||||
final goVip = await GuideFreeTrialSheet.show();
|
||||
// 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放
|
||||
if (!wasPlaying || goVip || isDispose || isShowBuy.value) return;
|
||||
playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _payGuideShown = false; // 本页「付费前置提示」是否已弹过(一次页面只弹一次)
|
||||
|
||||
/// 要花金币买或要开会员才能看完整片(不要求配了试看地址)
|
||||
bool get _needPayGuide => videoStatus.isNeedPay;
|
||||
|
||||
/// 能连续看的秒数:有试看片就是试看片长,否则是正片免费时长
|
||||
int get _freeSeconds => videoModel?.previewURL?.isNotEmpty == true
|
||||
? (playerCtr?.value.duration.inSeconds ?? 0)
|
||||
: (videoModel?.freeTime ?? 0);
|
||||
|
||||
/// 各类「整片免看」权益:免费次数 / 免费区 / 动漫整本已购 / 动漫免费章节 / VIP 金币限免。
|
||||
/// 命中任一说明用户本来就能看完整片,场景1、场景2 都不该打扰他。
|
||||
/// [_onProgress] 与场景1 共用,避免两边判断漂移。
|
||||
/// (useFreePlay 有扣次数副作用,但 _initPlayer 起手已调过一次,之后都走 usedIds 幂等返回)
|
||||
bool get _hasFreeWatchRight {
|
||||
if (FreePlayManager().useFreePlay(videoModel)) return true;
|
||||
if (videoModel?.freeArea == true) return true;
|
||||
if (cartoonModel?.mediaStatus?.hasPaid == true)
|
||||
return true; // 动漫整本解锁, 都可以看, 不管vip权限
|
||||
if (isCartoon &&
|
||||
cartoonModel?.isFreeEpisodeNumber(currentEpisode?.episodeNumber) ==
|
||||
true) return true;
|
||||
return isVipCoinFree;
|
||||
}
|
||||
|
||||
/// 起播前置引导(VIDEO_PREVIEW_END):需付费的视频起播 3 秒后前置弹一次购买引导
|
||||
/// (暂停,关掉后续播,一次进页面只弹一次)。
|
||||
/// 与超免费时长/试看结束的硬拦截(BuyVipAlert)都保留;本引导弹前会 pause,
|
||||
/// 期间进度不走、硬拦截触发不了,两者天然错开,不靠 TimedPopupManager 互斥。
|
||||
/// 计的是起播后的墙钟 3 秒(不要求真播满 3 秒进度);暂停后再播会重新计时。
|
||||
void _startPayGuideTimer() {
|
||||
if (_freeSeconds < 3) return; // 不足3秒:没等到提示就已被试看结束的硬拦截拦下了
|
||||
TimedPopupManager().schedule(
|
||||
type: TimedPopupType.videoPayAhead,
|
||||
delay: const Duration(seconds: 3),
|
||||
//到点再校验一次:3秒内买了 / 开了会员 / 进了全屏都不弹(全屏弹会连横竖屏一起乱)
|
||||
//_hasFreeWatchRight:能免费看完整片的别拿"只能看试看"的文案打扰他
|
||||
canShow: () =>
|
||||
!isDispose &&
|
||||
!_payGuideShown &&
|
||||
!isFullScreen &&
|
||||
_needPayGuide &&
|
||||
!_hasFreeWatchRight &&
|
||||
GuideManager().canShow(GuideScene.videoPreviewEnd),
|
||||
onShow: () async {
|
||||
// 到点还在播才弹(缓冲期 isPlaying 仍为 true,不违背"起播3秒")。
|
||||
// 挡掉:这3秒里手动暂停了 / 点推荐开了新视频页(先 pause 再 push,旧页没销毁、Timer 照跑)
|
||||
if (playerCtr?.value.isPlaying != true) return;
|
||||
_payGuideShown = true;
|
||||
// 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下
|
||||
if (await loadGuideCard(GuideScene.videoPreviewEnd) == null ||
|
||||
isDispose) return;
|
||||
playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update()
|
||||
final goVip = await _showPayGuide();
|
||||
// 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放
|
||||
if (goVip || isDispose || isShowBuy.value) return;
|
||||
playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 起播前置引导弹窗(VIDEO_PREVIEW_END)。返回 true = 点了开通并已跳会员页
|
||||
/// (试看结束/超时长已改弹 BuyVipAlert 硬拦截,不再共用这套文案)
|
||||
/// 长视频不传 buttonText,走默认「立即开通 · 仅需¥价格」
|
||||
Future<bool> _showPayGuide() => GuideBottomSheet.showVideo();
|
||||
|
||||
bool _backGuideShown = false; // 本页「返回优惠挽留」是否已弹过(一次页面只弹一次)
|
||||
|
||||
/// 是否允许直接退页。false=先弹返回挽留(VIDEO_BACK):长视频 + 场景开关允许 + 本页未弹过。
|
||||
/// 供 PopScope.canPop 与返回箭头共用,类型跟当前在播内容走
|
||||
bool get canPop =>
|
||||
_backGuideShown ||
|
||||
isCartoon ||
|
||||
!GuideManager().canShow(GuideScene.videoBack);
|
||||
|
||||
/// VIDEO_BACK:长视频返回挽留。弹优惠倒计时,标记已弹并 update() 让 canPop 放行后续返回。
|
||||
/// 用户是按了返回才弹的,所以挽留失败(点X/点蒙层/被互斥挡下)就把这次返回执行完;
|
||||
/// 点了「立即开通」已跳会员页则留在本页,从会员页回来还能接着看
|
||||
void showBackGuide() async {
|
||||
if (_backGuideShown) return;
|
||||
_backGuideShown = true;
|
||||
update(); // 刷新 PopScope.canPop → 关掉弹窗后再次返回可直接退页
|
||||
var goVip = false;
|
||||
// 取卡链路(签名/网络)抛异常绝不能让返回失灵:吞掉照样放行返回
|
||||
try {
|
||||
await TimedPopupManager().trigger(
|
||||
canShow: () => GuideManager().canShow(GuideScene.videoBack),
|
||||
onShow: () async => goVip =
|
||||
await GuideCountdownDialog.show(scene: GuideScene.videoBack),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLog('showBackGuide', e.toString());
|
||||
}
|
||||
if (!goVip && !isDispose) Get.back();
|
||||
}
|
||||
|
||||
/// UI 返回箭头统一入口:可退直接退,否则弹返回挽留。
|
||||
void onBackPressed() {
|
||||
if (canPop) {
|
||||
Get.back();
|
||||
} else {
|
||||
showBackGuide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取视频/动漫详情,写入观看历史,初始化播放
|
||||
/// - [newId] 非空:视频内切换集数 / 切换视频时复用本流程,传新 id
|
||||
Future loadData({String? newId}) async {
|
||||
if (isDispose) return;
|
||||
_forceH264 = false; // 新视频复位:重新按设备能力试 265(运行时回退不跨视频)
|
||||
final id = newId ?? videoModel?.id ?? vid ?? "";
|
||||
Object? detailError; // 诊断用:拉详情抛的异常
|
||||
try {
|
||||
if (isCartoon == true) {
|
||||
cartoonModel = await ACGService.getMediaInfo(id);
|
||||
int curPage = cartoonModel?.episodeCurPage ?? 0;
|
||||
final episodeModel = await ACGService.getChapterList(
|
||||
id,
|
||||
curPage + 1,
|
||||
cartoonModel?.episodePageSize ?? 40,
|
||||
sortType: cartoonModel?.isUpSort,
|
||||
);
|
||||
cartoonModel?.episodeList = episodeModel?.list;
|
||||
if (episodeModel?.list?.isNotEmpty != true) {
|
||||
showToast("资源配置错误~");
|
||||
Get.back();
|
||||
return;
|
||||
}
|
||||
cartoonModel?.episodeCurPage = curPage + 1;
|
||||
cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true;
|
||||
videoModel?.mediaInfo = cartoonModel;
|
||||
currentEpisode = episodeModel?.list?.first;
|
||||
MediaContent? mediaContent =
|
||||
await ACGService.getMediaDetail(id: currentEpisode?.id ?? "");
|
||||
episodeModel?.list?.first.mediaContent = mediaContent;
|
||||
videoModel = cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel();
|
||||
} else {
|
||||
videoModel = await VidService.getDetail(id);
|
||||
}
|
||||
isShowBuy.value = false;
|
||||
update();
|
||||
} catch (e) {
|
||||
detailError = e; // 留住异常给诊断用:为空说明接口通了但 data 是 null(解析失败/服务端空数据)
|
||||
debugLog(e.toString());
|
||||
}
|
||||
if (isDispose) return; // 拉详情期间可能已退页
|
||||
// 网络失败时 videoModel/mediaInfo 为 null,直接用 videoModel! 会崩("Null check operator"),兜底退出
|
||||
if (videoModel == null || (isCartoon && videoModel?.mediaInfo == null)) {
|
||||
PlayDiagnose.report(
|
||||
scene: '视频详情接口失败',
|
||||
error: detailError ?? '接口无异常但返回 null(解析失败或服务端空数据)',
|
||||
videoId: id,
|
||||
videoTitle: isCartoon ? '动漫' : '视频',
|
||||
);
|
||||
showToast("视频资源加载失败!");
|
||||
Get.back();
|
||||
return;
|
||||
}
|
||||
if (isCartoon == true) {
|
||||
HistoryUtil.insert(videoModel!.mediaInfo!, MediaStyle.Cartoon);
|
||||
} else {
|
||||
HistoryUtil.insert(videoModel!, MediaStyle.Video);
|
||||
}
|
||||
_initPlayer();
|
||||
// 免费次数满5分钟引导 & 支付分层首弹:都只在首次进入页面触发,页面内切集/换源不重复(一次页面只弹一次)
|
||||
if (newId == null) {
|
||||
_startFreeWatchTimer();
|
||||
alertVipFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/// 购买成功:标记已购 + 关蒙层 + 重建播放器切正片。
|
||||
/// 只改 hasPaid 不重建的话画面还停在预览片,必须重走 _initPlayer 重新选流,且从头播
|
||||
void onBuySuccess() {
|
||||
if (isDispose) return;
|
||||
isShowDialog = false;
|
||||
isShowBuy.value = false;
|
||||
videoModel?.vidStatus?.hasPaid = true;
|
||||
startPlay(resetPos: true);
|
||||
update();
|
||||
}
|
||||
|
||||
/// CDN 路线切换:同视频换 URL 重新起播(由 VideoTabbarMenuWidget 选完线路回调)
|
||||
void onSwitchLine() {
|
||||
startPlay();
|
||||
update();
|
||||
}
|
||||
|
||||
//hasAd: false 没有广告, true 广告还在
|
||||
void setAdShowing(bool hasAd) {
|
||||
isShowAd = hasAd;
|
||||
}
|
||||
|
||||
/// 开始播放
|
||||
/// 注:贴片广告期间不直接 play,由广告结束回调触发本方法(见 video_page.dart 的 onFinish)
|
||||
void play() {
|
||||
if (isDispose || isShowAd) return; // 有贴片广告时不自动播,等广告结束回调
|
||||
playerCtr?.play();
|
||||
_startPayGuideTimer(); // 需付费的起播满3秒 → 前置购买引导(场景1);不满足条件的它自己会挡
|
||||
}
|
||||
|
||||
/// 进入全屏(贴片广告中或控制器未就绪 → 不进)
|
||||
void toFullPage() async {
|
||||
if (isShowAd || playerCtr?.value.isInitialized != true) {
|
||||
return;
|
||||
}
|
||||
isFullScreen = true;
|
||||
await Get.to(
|
||||
() => VideoFullPage(playCtr: playerCtr!, videoModel: videoModel));
|
||||
isFullScreen = false;
|
||||
}
|
||||
|
||||
/// 退出全屏
|
||||
void exitFullScreen() {
|
||||
if (!isFullScreen) return;
|
||||
isFullScreen = false;
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top]);
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
Get.back();
|
||||
}
|
||||
|
||||
/// 点击广告跳金币充值,回到本页时如原本在播放则恢复播放
|
||||
void clickAdToVip() async {
|
||||
bool isPlaying = playerCtr?.value.isPlaying ?? false;
|
||||
playerCtr?.pause();
|
||||
await pushToWalletPage(tabPosition: 0);
|
||||
if (isPlaying) play();
|
||||
await globalStore.updateUserInfo();
|
||||
if (globalStore.isVIP) {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化播放器:拉贴片广告 → 定广告展示 → 选流(中文/无码/预览/265) → init + 挂 [_onProgress]。
|
||||
/// 仅网络播放不走本地文件;[pos] >2 时续播到该秒(切线路场景)
|
||||
void _initPlayer({int pos = 0, bool isRetry = false}) async {
|
||||
if (isDispose) return;
|
||||
FreePlayManager().useFreePlay(videoModel);
|
||||
adsList = AdManager().adsByType(50);
|
||||
//vip/已购视频不展示广告;否则配置了广告且配了关闭时间才展示
|
||||
final noAd = globalStore.isVIP || videoModel?.vidStatus?.hasPaid == true;
|
||||
setAdShowing(!noAd && adsList.firstOrNull?.hasAdTime == true);
|
||||
// 选流整合进 model.realPlayUrl;265 由设备硬解能力 + 本条是否已运行时回退共同决定
|
||||
videoUrl = videoModel?.realPlayUrl(
|
||||
canPlayPreview: _isPreview(),
|
||||
useH265: CodecSupport.useH265 && !_forceH264,
|
||||
);
|
||||
// 选中的就是 265 源时当场记录(此刻 token/cdn 与拼 videoUrl 时一致,比较可靠)
|
||||
final h265Url = videoModel?.realH265Url ?? '';
|
||||
isPlayingH265 = h265Url.isNotEmpty && videoUrl == h265Url;
|
||||
playerCtr = PlayerFactory.network(videoUrl); // 仅网络播放,不走本地文件
|
||||
try {
|
||||
await playerCtr?.initialize();
|
||||
if (isRetry)
|
||||
confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地
|
||||
update();
|
||||
} catch (e) {
|
||||
debugLog("视频初始化失败:$e play url = $videoUrl");
|
||||
playerCtr?.dispose(); // 初始化失败要释放,否则半初始化的解码器一直被占,多次后所有视频都加载失败
|
||||
playerCtr = null;
|
||||
if (isDecoderError(e)) {
|
||||
// 1) 先按芯片兼容机制内存切 platformView 重试(保留硬解/265,海思等渲染错常能救回,不误丢 265)
|
||||
if (switchToPlatformView()) {
|
||||
_initPlayer(pos: pos, isRetry: true);
|
||||
return;
|
||||
}
|
||||
// 2) platformView 也救不了且当前放的是 265:本机永久禁用 265 并落本地,用 264 重试
|
||||
if (isPlayingH265 && CodecSupport.disableForDevice()) {
|
||||
_initPlayer(pos: pos, isRetry: true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3) 非解码类错误(404/域名不通/非法 m3u8):只是这条 265 资源坏,与芯片无关 → 仅本条回退 264。
|
||||
// isRetry 透传而非写死 true:没切过 platformView 就别让 264 播通了去误标它
|
||||
if (isPlayingH265 && !_forceH264) {
|
||||
_forceH264 = true;
|
||||
debugLog("H.265 资源不可用,回退 264 兜底重试");
|
||||
_initPlayer(pos: pos, isRetry: isRetry);
|
||||
return;
|
||||
}
|
||||
PlayDiagnose.report(
|
||||
scene: '长视频播放失败',
|
||||
error: e,
|
||||
url: videoUrl,
|
||||
videoId: videoModel?.id,
|
||||
videoTitle: videoModel?.title,
|
||||
isH265: isPlayingH265,
|
||||
forceH264: _forceH264,
|
||||
);
|
||||
showToast("视频资源加载失败!");
|
||||
return;
|
||||
}
|
||||
if (isDispose) {
|
||||
playerCtr?.pause();
|
||||
playerCtr?.dispose();
|
||||
playerCtr = null; // 退页后才初始化完成:释放并置空,防止 UI 重建摸到已 dispose 的控制器
|
||||
return;
|
||||
}
|
||||
presaleProvider.useCoinPrivilege(videoModel);
|
||||
playerCtr?.addListener(_onProgress);
|
||||
int previewStart = videoModel?.previewStart ?? 0;
|
||||
if (previewStart > 0) {
|
||||
final canFree = FreePlayManager().useFreePlay(videoModel) ||
|
||||
videoModel?.freeArea == true;
|
||||
// 免金币视频 / 有免费观看次数(vip视频):从头播,不需要 seekTo
|
||||
// 需要付费/会员才能看的视频,跳到预览起始位置开始播放
|
||||
if (!canFree && videoStatus.isNeedPay) {
|
||||
await playerCtr?.seekTo(Duration(seconds: previewStart));
|
||||
}
|
||||
} else if (pos > 2) {
|
||||
await playerCtr?.seekTo(Duration(seconds: pos));
|
||||
}
|
||||
play();
|
||||
update();
|
||||
}
|
||||
|
||||
/// 播放进度监听(每帧):有免看权益的全程不拦截;
|
||||
/// 正片到免费时长弹购买,预览片播完回 0 + 暂停 + 弹窗
|
||||
void _onProgress() async {
|
||||
// 265 运行时降级:播放器报错立即回退 264(不持久化)
|
||||
if (isPlayingH265 && playerCtr?.value.hasError == true) {
|
||||
_fallbackTo264();
|
||||
return;
|
||||
}
|
||||
int duration = playerCtr?.value.duration.inSeconds ?? 0;
|
||||
if (_hasFreeWatchRight) return; // 免费次数/免费区/动漫已购/免费章节/VIP金币限免,整片随便看
|
||||
if (!_isPreview()) {
|
||||
//每帧都走这里,videoStatus 是重算 getter,只读一次
|
||||
final status = videoStatus;
|
||||
final inFreeTime =
|
||||
videoModel?.isInFreeTime(playerCtr!.value.position.inSeconds) == true;
|
||||
final isPlaying = playerCtr?.value.isPlaying == true;
|
||||
if (inFreeTime || !isPlaying) return;
|
||||
// 全屏中:仍处于免费时长则不打扰;否则需付费的视频在播放时先退出全屏,给后续弹窗腾出空间
|
||||
final wasFullScreen = isFullScreen;
|
||||
if (wasFullScreen && status.isNeedPay) exitFullScreen();
|
||||
//超过免费时间:需金币购买 / 需开会员,都弹购买蒙层
|
||||
if (status.isNeedPay) {
|
||||
showBuyMask();
|
||||
// 非会员且免费次数已用完的 VIP 片:蒙层之上直接弹开通弹窗
|
||||
// (金币片仍只显示蒙层,由蒙层引导金币解锁/观影券;动漫的弹窗在 showBuyMask 内部处理,别在这叠)
|
||||
// showBuyMask 已 pause,下一帧 isPlaying=false 就提前 return,不会重复弹
|
||||
if (!isCartoon && status.isNeedVip) {
|
||||
// 刚退全屏要等 pop 动画和转屏落定再弹:exitFullScreen 的 Get.back() 带动画,
|
||||
// 此刻 push 会叠在正在 pop 的全屏页上,且弹窗高度取的是实时 Get.height,横屏下算出来是错的
|
||||
if (wasFullScreen)
|
||||
await Future.delayed(const Duration(milliseconds: 350));
|
||||
if (!isDispose) BuyVipAlert.show(videoId: videoModel?.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//预览视频逻辑处理
|
||||
if (playerCtr!.value.position.inSeconds == duration - 1) {
|
||||
await playerCtr!.seekTo(Duration.zero);
|
||||
playerCtr?.pause();
|
||||
exitFullScreen();
|
||||
Future.delayed(const Duration(milliseconds: 200), () async {
|
||||
if (isDispose) return;
|
||||
if (videoModel?.coins != 0) {
|
||||
showBuyMask();
|
||||
} else if (videoModel?.coins == 0 && videoModel?.freeArea != true) {
|
||||
///点击的必须弹出
|
||||
if (!isShowDialog) {
|
||||
isShowDialog = true;
|
||||
unawaited(showBuyMask()); // 只等蒙层显示,不等动漫购买弹窗关闭(与原逻辑一致)
|
||||
isShowDialog = false;
|
||||
await globalStore.updateUserInfo();
|
||||
//已开通 VIP → 重启播完整片;否则保持暂停(蒙层已显示,由蒙层引导开通),
|
||||
//不能再 play() 恢复预览,否则"试看结束"蒙层还在、视频却在后面继续播
|
||||
if (globalStore.isVIP) {
|
||||
startPlay();
|
||||
update();
|
||||
} else if (!isDispose) {
|
||||
// 试看结束:所有非会员统一弹开通弹窗,under7/over7 原来弹吸底分层引导,现收敛成一个
|
||||
// 不走 TimedPopupManager:这是硬拦截,被互斥挡下就等于没弹
|
||||
await BuyVipAlert.show(videoId: videoModel?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 265 播放中报错:仅本条回退 264 续播,不持久化。
|
||||
/// 回退后 isPlayingH265 自然为 false 不会重入,_forceH264 再兜住重建中的窗口期
|
||||
void _fallbackTo264() {
|
||||
if (isDispose || !isPlayingH265 || _forceH264) return;
|
||||
_forceH264 = true; // 本条视频后续重建都走 264
|
||||
debugLog(
|
||||
"H.265 运行时报错,回退 264 续播 pos=${playerCtr?.value.position.inSeconds}");
|
||||
releasePlayer(); // 释放当前 → 下一帧按进度续播,选流因 _forceH264 走 264
|
||||
}
|
||||
|
||||
/// 弹购买/VIP 蒙层(暂停 + 显示)。动漫 permission==1 走单集/全集购买(result 1单集 2全集),
|
||||
/// 其他走 VIP 弹窗;长视频只暂停+显示,交给 VideoMaskBuyView
|
||||
Future<void> showBuyMask() async {
|
||||
playerCtr?.pause();
|
||||
isShowBuy.value = true;
|
||||
update();
|
||||
if (isCartoon) {
|
||||
if (videoModel?.mediaInfo?.permission == 1) {
|
||||
var result = await ComicBuyAlert.show(mediaInfo: videoModel?.mediaInfo);
|
||||
|
||||
///true表示支付成功
|
||||
if (result == 1 || result == 2) {
|
||||
videoModel?.vidStatus?.hasPaid = true;
|
||||
currentEpisode?.hasBuy = true;
|
||||
if (result == 2) {
|
||||
// 全集购买
|
||||
videoModel?.mediaInfo?.mediaStatus?.hasPaid = true;
|
||||
}
|
||||
playerCtr?.play();
|
||||
}
|
||||
update();
|
||||
} else {
|
||||
await BuyVipAlert.show(videoId: videoModel?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否走预览视频(短预告片)
|
||||
/// 条件:用户没有免播次数 + 配了 previewURL + 视频需要付费/VIP
|
||||
bool _isPreview() {
|
||||
if (FreePlayManager().useFreePlay(videoModel) == true) return false;
|
||||
return videoModel?.previewURL?.isNotEmpty == true && videoStatus.isNeedPay;
|
||||
}
|
||||
|
||||
/// 推荐区切内容(释放控制器 + 重拉详情起播,不开新页)。
|
||||
/// **必须按被点内容的 videoType 定接口**,用页面入参会串接口 → 「视频资源加载失败」
|
||||
Future<void> switchVideo(VideoModel model) async {
|
||||
videoModel = model;
|
||||
isShowBuy.value = false;
|
||||
isCartoon = model.videoType == 1;
|
||||
if (!isCartoon) {
|
||||
// 切到真人视频:清掉上一部动漫的章节态,避免选集/免费章节逻辑串到真人视频
|
||||
cartoonModel = null;
|
||||
currentEpisode = null;
|
||||
}
|
||||
final old = playerCtr;
|
||||
old?.pause();
|
||||
old?.removeListener(_onProgress);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => old?.dispose());
|
||||
loadData(newId: model.id);
|
||||
playerCtr = null;
|
||||
update();
|
||||
}
|
||||
|
||||
/// 切视频或切 CDN 路线:[model] 非空=切新视频,为空=同视频换 URL;控制器为空走首次进入路径
|
||||
Future<void> startPlay({VideoModel? model, bool resetPos = false}) async {
|
||||
if (model != null) {
|
||||
videoModel = model;
|
||||
}
|
||||
if (playerCtr == null) {
|
||||
_initPlayer();
|
||||
} else {
|
||||
releasePlayer(model: model, resetPos: resetPos);
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
/// 动漫切换集数:同集 → toast 提示;不同集 → 释放当前 + 拉新集 mediaContent + 启动播放
|
||||
void switchEpisode(ComicChapterInfo episode) async {
|
||||
if (episode.id == currentEpisode?.id) {
|
||||
showToast("正在播放");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
currentEpisode = episode;
|
||||
isShowBuy.value = false;
|
||||
releasePlayer(rebuild: false);
|
||||
update();
|
||||
currentEpisode?.mediaContent ??=
|
||||
await ACGService.getMediaDetail(id: currentEpisode?.id ?? "");
|
||||
startPlay(
|
||||
model: cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel());
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放播放器。[rebuild]=false 只释放(切集前清理);[model] 非空则切新视频,为空则按上次进度续播。
|
||||
/// dispose 走下一帧,避免在播放回调中销毁导致 use-after-free
|
||||
void releasePlayer(
|
||||
{VideoModel? model, bool rebuild = true, bool resetPos = false}) {
|
||||
final old = playerCtr;
|
||||
old?.pause();
|
||||
// resetPos:预览片→正片场景,两者时间轴不对应,不能沿用预览片进度,从头播
|
||||
final pos = resetPos ? 0 : (old?.value.position.inSeconds ?? 0);
|
||||
old?.removeListener(_onProgress);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
old?.dispose();
|
||||
if (isDispose || !rebuild) return; // 退页后别再重建播放器,否则野解码器泄漏
|
||||
if (model != null) {
|
||||
isShowBuy.value = false;
|
||||
update();
|
||||
loadData(newId: model.id);
|
||||
} else {
|
||||
_initPlayer(pos: pos);
|
||||
}
|
||||
});
|
||||
playerCtr = null;
|
||||
}
|
||||
|
||||
/// 页面销毁清理:取消事件订阅 → 非预览视频上报观看记录 → 释放播放器
|
||||
@override
|
||||
void onClose() {
|
||||
isDispose =
|
||||
true; // 第一时间置位,让所有异步回调(initialize/postFrame/Future.delayed)能尽早 return
|
||||
TimedPopupManager().cancel(TimedPopupType.freeWatchPay);
|
||||
TimedPopupManager().cancel(TimedPopupType.videoPayAhead);
|
||||
|
||||
playerCtr?.pause();
|
||||
playerCtr?.removeListener(_onProgress);
|
||||
_pauseVideoSub?.cancel();
|
||||
if (playerCtr != null) {
|
||||
bool isPreview = false;
|
||||
if (videoModel?.previewURL?.isNotEmpty == true) {
|
||||
if (playerCtr?.dataSource.contains(videoModel?.previewURL ?? "") ==
|
||||
true) {
|
||||
isPreview = true;
|
||||
}
|
||||
}
|
||||
if (!isPreview) {
|
||||
try {
|
||||
_reportPlay(
|
||||
playerCtr?.value.position, playerCtr?.value.duration, videoModel);
|
||||
} catch (e) {
|
||||
debugLog(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
playerCtr?.dispose();
|
||||
playerCtr = null;
|
||||
tabCtr.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
/// 上报播放记录(static:不碰实例状态,避免请求在飞时把已销毁的 logic 一起吊住)
|
||||
static Future<void> _reportPlay(
|
||||
Duration? position, Duration? duration, VideoModel? video) async {
|
||||
if (position == null || duration == null) return;
|
||||
if (position.inMilliseconds < 1000) return; // 不足 1 秒不记
|
||||
if (video?.videoType == 1) return; // 动漫不走这个接口
|
||||
|
||||
// 播放器拿不到时长时退回视频配置的 playTime
|
||||
var totalMs = duration.inMilliseconds;
|
||||
if (totalMs <= 0) totalMs = (video?.playTime ?? 0) * 1000;
|
||||
if (totalMs <= 0) return; // 时长未知,算不出进度
|
||||
|
||||
try {
|
||||
await VidService.sendRecord(
|
||||
video?.id ?? '',
|
||||
playWay: video?.coins == 0 ? 0 : 1,
|
||||
longer: position.inSeconds,
|
||||
progress: (position.inMilliseconds / totalMs * 100).toInt(),
|
||||
via: 1,
|
||||
tagID: video?.tags?.firstOrNull?.id ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
debugLog('_reportPlay', 'error:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
// ignore_for_file: use_build_context_synchronously, file_names
|
||||
|
||||
import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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_page/video/video_logic.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_ad_widget.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_mask_buy_view.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_menu_view.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_status_view.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_tabbar_menu_widget.dart';
|
||||
import 'package:hgdj/hj_page/video/view/vip_promo_banner.dart';
|
||||
import 'package:hgdj/hj_utils/codec_support.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
|
||||
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../hj_model/video_model.dart';
|
||||
import '../../tools_base/widget/net_image_widget.dart';
|
||||
import '../comment/comment_views.dart';
|
||||
import 'cartoon/video_detail_cartoon_view.dart';
|
||||
import 'view/video_detail_view.dart';
|
||||
|
||||
class VideoPage extends StatefulWidget {
|
||||
final bool isCartoon;
|
||||
final VideoModel? model;
|
||||
final String? id;
|
||||
final String? videoCover;
|
||||
|
||||
VideoPage({
|
||||
super.key,
|
||||
this.model,
|
||||
this.isCartoon = false,
|
||||
this.id,
|
||||
this.videoCover,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoPage> createState() => _VideoPageState();
|
||||
}
|
||||
|
||||
class _VideoPageState extends State<VideoPage> with UniqueTagMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<VideoLogic>(
|
||||
init: VideoLogic(
|
||||
vid: widget.id ?? widget.model?.id,
|
||||
isCartoon: widget.isCartoon,
|
||||
),
|
||||
tag: uniqueTag,
|
||||
builder: (logic) => PopScope(
|
||||
// 场景3:长视频返回挽留。iOS 侧滑在 canPop=false 时会被整体禁用且不回调,没法用来弹窗,
|
||||
// 故 iOS 恒可退、仅靠返回箭头(onBackPressed)弹挽留;Android 用 canPop 拦硬件返回键。
|
||||
canPop: GetPlatform.isIOS || logic.canPop,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
logic.showBackGuide();
|
||||
},
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: logic.normalRatio,
|
||||
child: _buildPlayContent(logic),
|
||||
),
|
||||
if (logic.videoModel != null) ...[
|
||||
VipPromoBanner(
|
||||
playCtr: logic.playerCtr,
|
||||
videoModel: logic.videoModel,
|
||||
),
|
||||
VideoTabbarMenuWidget(
|
||||
logic.tabCtr,
|
||||
model: logic.videoModel,
|
||||
onSwitchLine: logic.onSwitchLine,
|
||||
),
|
||||
Expanded(child: _buildTabDetailInfo(logic)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部 TabBarView:第 0 页详情/推荐,第 1 页评论(与 VideoTabbarMenuWidget 共用 logic.tabCtr)
|
||||
/// 注:选哪个详情视图、切视频走哪条接口,都以当前数据的 videoType 为准,别用页面入参 widget.isCartoon
|
||||
Widget _buildTabDetailInfo(VideoLogic logic) {
|
||||
final model = logic.videoModel;
|
||||
final isCartoonVideo = model?.videoType == 1;
|
||||
return TabBarView(
|
||||
controller: logic.tabCtr,
|
||||
children: [
|
||||
if (isCartoonVideo)
|
||||
VideoDetailCartoonView(
|
||||
model: model,
|
||||
currentEpisode: logic.currentEpisode,
|
||||
playCtr: logic.playerCtr,
|
||||
vmCallback: logic.switchVideo,
|
||||
episoCallback: logic.switchEpisode,
|
||||
).keepAlive
|
||||
else
|
||||
VideoDetailView(
|
||||
model: model,
|
||||
playCtr: logic.playerCtr,
|
||||
vmCallback: logic.switchVideo,
|
||||
).keepAlive,
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: CommentView(
|
||||
// key 跟着 id 走:页内切内容后强制重建,否则 GetBuilder 只在 initState 注册一次 controller,
|
||||
// objId 变了评论区还是上一部的(推荐区靠 ValueKey('rec_$id') 解决的是同一个问题)
|
||||
key: ValueKey('cmt_${model?.id}'),
|
||||
model?.id ?? '',
|
||||
objType: isCartoonVideo ? "cartoon" : "video",
|
||||
),
|
||||
).keepAlive,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放器外层:监听 videoModel.id 变化,切换视频时触发 fade + scale 动画
|
||||
Widget _buildPlayContent(VideoLogic logic) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, anim) => FadeTransition(
|
||||
opacity: anim,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.9, end: 1.0).animate(anim),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
// KeyedSubtree 的 key 由 videoId 决定:id 变化 → AnimatedSwitcher 触发动画
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(logic.videoModel?.id ?? 'empty'),
|
||||
child: _buildPlayBody(logic),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放器主体(视频画面 + 控制层 + 蒙层 + 贴片广告 + 返回按钮)
|
||||
Widget _buildPlayBody(VideoLogic logic) {
|
||||
return Container(
|
||||
color: AppColors.primaryColor,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _togglePlayPause(logic),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
child: AspectRatio(
|
||||
aspectRatio: logic.videoRatio,
|
||||
child: logic.playerCtr != null
|
||||
? VideoPlayer(logic.playerCtr!)
|
||||
: SizedBox(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (logic.playerCtr?.value.isInitialized != true)
|
||||
NetworkImageLoader(
|
||||
imageUrl: logic.videoModel?.cover ?? widget.videoCover ?? "",
|
||||
borderRadius: 0),
|
||||
if (logic.playerCtr?.value.isInitialized == true) ...[
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 16,
|
||||
child: VipFreeTipView(
|
||||
key: ValueKey("status_top_${logic.videoModel?.id}"),
|
||||
videoModel: logic.videoModel,
|
||||
),
|
||||
),
|
||||
VideoMenuView(
|
||||
key: ValueKey("VideoMenuView_${logic.videoModel?.id}"),
|
||||
playCtr: logic.playerCtr!,
|
||||
videoModel: logic.videoModel,
|
||||
onBuyEvent: logic.showBuyMask,
|
||||
onFullScreen: logic.toFullPage,
|
||||
),
|
||||
],
|
||||
Positioned.fill(child: _bufferLoading(logic)),
|
||||
Positioned.fill(
|
||||
child: Obx(
|
||||
() => Visibility(
|
||||
visible: logic.isShowBuy.value,
|
||||
child: VideoMaskBuyView(
|
||||
logic.videoModel,
|
||||
playCtr: logic.playerCtr,
|
||||
// 购买成功要重建播放器换成正片,否则画面停在预览片(要退出重进才正常)
|
||||
onBuySucc: () => logic.onBuySuccess(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (logic.isShowAd && logic.adsList.isNotEmpty)
|
||||
VideoAdWidget(
|
||||
adsInfos: logic.adsList,
|
||||
showBackArrow: false,
|
||||
onFinish: () {
|
||||
logic.setAdShowing(false);
|
||||
logic.update();
|
||||
logic.play();
|
||||
},
|
||||
onToVip: () async {
|
||||
logic.clickAdToVip();
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: GestureDetector(
|
||||
onTap: () => logic.onBackPressed(),
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.centerLeft,
|
||||
margin: EdgeInsets.only(top: 10, left: 18.w),
|
||||
child: Image.asset(
|
||||
"back_circle.png".commonImgPath,
|
||||
width: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (kDebugMode) _buildDebugCodecTag(logic),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓冲中的居中转圈。直接监听 playCtr(本身是 ValueNotifier),
|
||||
/// 切视频/切线路换了控制器时 ValueListenableBuilder 会自动改订阅,无需手动 add/removeListener
|
||||
Widget _bufferLoading(VideoLogic logic) {
|
||||
final ctr = logic.playerCtr;
|
||||
if (ctr == null) return const SizedBox();
|
||||
return ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: ctr,
|
||||
builder: (_, value, __) => value.isInitialized && value.isBuffering
|
||||
? const Center(
|
||||
child: CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed, radius: 15))
|
||||
: const SizedBox(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 【仅 debug】左下角编码排查标签:显示当前走 H265/H264,点击复制播放链接
|
||||
Widget _buildDebugCodecTag(VideoLogic logic) {
|
||||
final isH265 = logic.isPlayingH265;
|
||||
final deviceSupport = CodecSupport.useH265; // 设备硬解 265 探测结果
|
||||
return Positioned(
|
||||
left: 10,
|
||||
bottom: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final url = logic.videoUrl ?? '';
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
showToast('已复制播放链接');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: .6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${isH265 ? "H265" : "H264"} 设备265:${deviceSupport ? "✓" : "✗"}',
|
||||
style: TextStyle(
|
||||
color: isH265 ? const Color(0xff4CAF50) : const Color(0xffFFC107),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击播放器:播放中则暂停,否则播放(ctr 为 null 时不操作)
|
||||
void _togglePlayPause(VideoLogic logic) {
|
||||
final ctr = logic.playerCtr;
|
||||
if (ctr == null) return;
|
||||
if (ctr.value.isPlaying) {
|
||||
ctr.pause();
|
||||
} else {
|
||||
ctr.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_page/video/video_full_page.dart';
|
||||
import 'package:hgdj/hj_page/video/view/video_menu_view.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/hj_utils/video_view_type.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../tools_base/widget/net_image_widget.dart';
|
||||
|
||||
class VideoPlayerAlert extends StatefulWidget {
|
||||
final String? movieUrl;
|
||||
final String? imgCover;
|
||||
|
||||
const VideoPlayerAlert(this.movieUrl, {this.imgCover, super.key});
|
||||
|
||||
@override
|
||||
State<VideoPlayerAlert> createState() => _VideoPlayerAlertState();
|
||||
}
|
||||
|
||||
class _VideoPlayerAlertState extends State<VideoPlayerAlert> {
|
||||
VideoPlayerController? videoCtr;
|
||||
StreamSubscription? pauseSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 耳机/蓝牙断开、来电中断时暂停,防止外放泄露
|
||||
pauseSub = eventBus.on<PauseVideoEvent>((_) => videoCtr?.pause());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initStatus());
|
||||
}
|
||||
|
||||
void _initStatus({bool isRetry = false}) async {
|
||||
videoCtr = PlayerFactory.network(widget.movieUrl);
|
||||
try {
|
||||
await videoCtr?.initialize();
|
||||
if (isRetry)
|
||||
confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地
|
||||
if (mounted) {
|
||||
videoCtr?.play();
|
||||
setState(() {});
|
||||
}
|
||||
} catch (e) {
|
||||
videoCtr?.dispose();
|
||||
// 首次芯片解码/渲染报错:仅内存切 platformView 重试,成功后才落本地
|
||||
if (isDecoderError(e) && switchToPlatformView()) {
|
||||
_initStatus(isRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pauseSub?.cancel();
|
||||
videoCtr?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.zero,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildContent(),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: Get.back,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(left: 16, top: 6),
|
||||
child: Image.asset('back_circle.png'.commonImgPath,
|
||||
width: 24, height: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
// 未初始化完成:展示封面 + loading
|
||||
if (videoCtr?.value.isInitialized != true) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (widget.imgCover?.isNotEmpty == true)
|
||||
NetworkImageLoader(imageUrl: widget.imgCover ?? ""),
|
||||
LoadingCenterWidget(),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
width: screen.screenWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: videoCtr!.value.aspectRatio,
|
||||
child: VideoPlayer(videoCtr!),
|
||||
),
|
||||
),
|
||||
VideoMenuView(
|
||||
playCtr: videoCtr!,
|
||||
isFull: true,
|
||||
onFullScreen: () =>
|
||||
Get.to(() => VideoFullPage(playCtr: videoCtr!, isAutoV: false)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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