初始化

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