初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+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();
}
}
}